diff --git a/.dev.vars.example b/.dev.vars.example index b2ba5081..44eea820 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,8 +1,18 @@ GITHUB_CLIENT_ID=your_client_id_here GITHUB_CLIENT_SECRET=your_client_secret_here +# Note: ALLOWED_ORIGIN must match the registered OAuth callback origin. +# For Jira local dev, this must be http://localhost:5173 — the Worker constructs +# redirect_uri as ${ALLOWED_ORIGIN}/jira/callback, which Atlassian validates against +# the registered callback URLs in your Atlassian Developer Console app settings. ALLOWED_ORIGIN=http://localhost:5173 SESSION_KEY=your-base64-encoded-32-byte-key SEAL_KEY=your-base64-encoded-32-byte-key TURNSTILE_SECRET_KEY=your-turnstile-secret-from-cf-dashboard # Optional: only needed if Sentry "Allowed Domains" is configured in your Sentry project settings # SENTRY_SECURITY_TOKEN=your-sentry-security-token + +# ── Jira Cloud Integration (optional) ───────────────────────────────────────── +# Get these from the Atlassian Developer Console: https://developer.atlassian.com/console/myapps/ +# Create an OAuth 2.0 (3LO) app, add read:jira-work scope, set callback to ${ALLOWED_ORIGIN}/jira/callback +# JIRA_CLIENT_ID=your-jira-oauth-client-id +# JIRA_CLIENT_SECRET=your-jira-oauth-client-secret diff --git a/.env.example b/.env.example index eb5c8139..06f2574b 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ GITHUB_TOKEN=your_github_token_here # Default: 9876 # MCP_WS_PORT=9876 +# ── Jira Cloud Integration (optional) ───────────────────────────────────────── +# Public Jira OAuth client ID — embedded into client-side bundle at build time by Vite. +# This is public information (visible in the OAuth authorize URL). +# VITE_JIRA_CLIENT_ID=your-jira-oauth-client-id + # ── Turnstile (Cloudflare) ───────────────────────────────────────────────────── # Public site key — embedded into client-side bundle at build time by Vite. # This is public information (visible in the Turnstile widget script). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2cd53bc..1eb2d411 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,13 @@ pnpm run dev The dev server starts at `http://localhost:5173`. You'll need a GitHub OAuth app client ID in `.env` (copy `.env.example` and fill in your value). +**Jira integration (optional):** The Jira Cloud integration is opt-in. Tests run without Jira credentials. To develop or test the Jira features locally: + +1. Add `VITE_JIRA_CLIENT_ID=` to `.env` — this gates visibility of the Jira section in Settings +2. Add `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET` to `.dev.vars` (copy from `.dev.vars.example`) — these are used by the Cloudflare Worker for OAuth token exchange + +In production, provision `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET` as Worker secrets via `wrangler secret put`. Never commit them to `.env` or `.dev.vars`. + The repo uses a pnpm workspace: the root package is the SolidJS SPA; `mcp/` is a separate package (`github-tracker-mcp`) built with tsup. Running `pnpm install` at the root installs both. To run the MCP server in standalone mode, set `GITHUB_TOKEN` before starting: diff --git a/DEPLOY.md b/DEPLOY.md index d1fc9af3..bf8e2d89 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -119,13 +119,12 @@ Cloudflare Worker secrets are set. In CI, the deploy workflow runs ### Scopes -The login flow requests `scope=repo read:org notifications`: +The login flow requests `scope=repo read:org`: | Scope | Used for | |-------|----------| | `repo` | Read issues, PRs, check runs, workflow runs (includes private repos) | | `read:org` | `GET /user/orgs` — list user's organizations for the org selector | -| `notifications` | `GET /notifications` — polling optimization gate (304 = skip full fetch) | **Note:** The `repo` scope grants write access to repositories, but this app never performs write operations (POST/PUT/PATCH/DELETE on repo endpoints). It is read-only by design. diff --git a/README.md b/README.md index 14de0e0f..5a2456c4 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ A second, faster poll loop (default 30s, configurable 10–120s) targets only in ### Desktop Notifications -Browser notifications for new issues, PRs, and failed runs. Per-type toggles in settings. Notification permission requested on first enable. Uses the GitHub Notifications API as a change-detection gate when the `notifications` scope is available. +Browser notifications for new issues, PRs, and failed runs. Per-type toggles in settings. Notification permission requested on first enable. New items are detected via the Events API polling loop and full refresh cycles. ### Repo Pinning and Reordering @@ -77,6 +77,10 @@ Star counts appear in repo group headers, fetched as part of the standard data r Create named filtered views over the existing Issues, PRs, and Actions data. Each custom tab has a name, a base type (Issues, PRs, or Actions), an optional org/repo scope, and optional filter presets. An "exclusive" toggle hides matching items from the standard tabs so they only appear in the custom tab. Up to 10 custom tabs can be created. Manage them via the "+" button in the tab bar or in **Settings > Custom Tabs**. +### Jira Cloud Integration + +Opt-in Jira Cloud integration: auto-detect Jira issue keys in GitHub issue and PR titles and display inline status badges, view your assigned Jira issues in a dedicated tab with status/priority filters, and bookmark Jira issues to the Tracked tab alongside GitHub items. Supports OAuth 2.0 (3LO) and API token authentication. + ### Themes 9 themes: auto (follows system), corporate, cupcake, light, nord, dim, dracula, dark, forest. Theme is applied immediately on selection with no page reload. @@ -87,7 +91,7 @@ Hide specific items with a persistent ignore list. An "N ignored" badge on the r ### ETag Caching and Auto-Refresh -Conditional requests using `If-None-Match` headers — GitHub doesn't count 304 responses against the rate limit. Background polling keeps data fresh even when the tab is hidden (when the notifications scope is available for efficient change detection). +Conditional requests using `If-None-Match` headers — GitHub doesn't count 304 responses against the rate limit. A 60-second events poll uses ETag requests to detect changes and trigger targeted per-repo refreshes, keeping data fresh even in background tabs at zero rate-limit cost. ## Tech Stack diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 13d73138..043d1516 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -36,6 +36,14 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi - [Tracked Items](#tracked-items) - [Repo Pinning](#repo-pinning) - [MCP Server Integration](#mcp-server-integration) +- [Jira Cloud Integration](#jira-cloud-integration) + - [Prerequisites](#jira-prerequisites) + - [Connecting via OAuth](#connecting-via-oauth) + - [Connecting via API Token](#connecting-via-api-token) + - [Issue Key Detection](#issue-key-detection) + - [Jira Assigned Tab](#jira-assigned-tab) + - [Bookmarking Jira Issues](#bookmarking-jira-issues) + - [Disconnecting](#disconnecting-jira) - [Settings Reference](#settings-reference) - [Troubleshooting](#troubleshooting) @@ -45,9 +53,9 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi ### OAuth Sign-In -OAuth is the recommended sign-in method. Click **Sign in with GitHub** on the login page and authorize the application. GitHub will redirect you back with a token that grants access to `repo`, `read:org`, and `notifications` scopes. +OAuth is the recommended sign-in method. Click **Sign in with GitHub** on the login page and authorize the application. GitHub will redirect you back with a token that grants access to `repo` and `read:org` scopes. -OAuth tokens work across all organizations you belong to and support the notifications optimization that reduces API usage in background tabs. +OAuth tokens work across all organizations you belong to. ### Personal Access Token Sign-In @@ -55,8 +63,8 @@ If you prefer not to use OAuth, you can sign in with a GitHub Personal Access To Two token formats are accepted: -- **Classic tokens** (starts with `ghp_`) — recommended. Works across all organizations you belong to. Required scopes: `repo`, `read:org` (under admin:org), `notifications`. -- **Fine-grained tokens** (starts with `github_pat_`) — also work, but have limitations: they only access one organization at a time, do not support the `notifications` scope, and therefore cannot use the background-poll optimization. Required permissions: Actions (read), Contents (read), Issues (read), Pull requests (read). +- **Classic tokens** (starts with `ghp_`) — recommended. Works across all organizations you belong to. Required scopes: `repo`, `read:org` (under admin:org). +- **Fine-grained tokens** (starts with `github_pat_`) — also work, but only access one organization at a time. Required permissions: Actions (read), Contents (read), Issues (read), Pull requests (read). The token is validated against the GitHub API before being stored. It is saved permanently in your browser's `localStorage` — you will not need to re-enter it on revisit. @@ -316,10 +324,10 @@ Hover the rate limit display in the dashboard footer to see detailed remaining c When the tab is hidden: - The **hot poll always pauses** (it provides only visual feedback). -- The **full poll continues in background** when the notifications gate is available (OAuth or classic PAT with `notifications` scope). The gate uses `If-Modified-Since` headers for near-zero-cost 304 checks that do not count against your rate limit. -- When the notifications gate is **unavailable** (fine-grained PAT or classic PAT missing the `notifications` scope), the full poll also pauses in background tabs to conserve API budget. +- The **full refresh pauses** in background tabs — GraphQL requests have no 304 shortcut and every poll consumes real rate-limit budget. +- The **events poll continues in background** — it uses ETag conditional requests (`If-None-Match`) that return 304 when nothing has changed, costing zero rate-limit points. When changes are detected, targeted per-repo refreshes run immediately. -When you return to a tab that has been hidden for more than 2 minutes, a catch-up fetch fires immediately regardless of where the timer is in its cycle. +When you return to a tab that has been hidden for more than 2 minutes, a catch-up full refresh fires immediately regardless of where the timer is in its cycle. --- @@ -418,6 +426,119 @@ The relay falls back to direct GitHub API calls automatically when the dashboard --- +## Jira Cloud Integration + +GitHub Tracker can optionally connect to Jira Cloud to show you assigned issues, detect Jira issue keys referenced in GitHub items, and let you bookmark Jira issues alongside GitHub items in the Tracked tab. + +The integration is opt-in and requires a Jira Cloud account. It can be enabled and disabled at any time from Settings. + +### Jira Prerequisites + +**Atlassian account:** You need a Jira Cloud account with access to at least one Jira site. + +**OAuth app (if using OAuth):** The app must be configured with a registered Atlassian OAuth 2.0 (3LO) client ID. If you are running your own deployment, register an app at [developer.atlassian.com](https://developer.atlassian.com/console/myapps/) with: +- **Classic scopes:** `read:jira-work`, `read:jira-user` +- **Callback URLs:** `https://your-domain/jira/callback` and `http://localhost:5173/jira/callback` (for local dev) +- Set `VITE_JIRA_CLIENT_ID` in `.env` and provision `JIRA_CLIENT_ID` + `JIRA_CLIENT_SECRET` as Worker secrets (see [Deployment](#jira-production-secrets)). + +**API token (if not using OAuth):** Generate one at [id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens). Use **Create API token** (not "Create API token with scopes") — this type inherits your account's full access to Jira projects. The app uses the token read-only: it searches for assigned issues and fetches issue details. + +### Connecting via OAuth + +OAuth is the recommended method. It gives the app short-lived access tokens refreshed automatically and does not require you to copy credentials. + +1. Go to **Settings > Jira Cloud Integration** +2. Click **Connect with Jira** +3. You will be redirected to Atlassian's consent screen — authorize the requested scopes (`read:jira-work`, `read:jira-user`) +4. If your account has access to multiple Jira sites, a site picker appears — select the site you want to use +5. You are redirected back to Settings with the integration active + +The integration label shows "OAuth" and displays the connected site name and URL. + +### Connecting via API Token + +Use this method if OAuth is unavailable (e.g., your organization does not allow third-party OAuth apps). + +1. Go to **Settings > Jira Cloud Integration** +2. Click **Use API token** to switch modes +3. Enter your Atlassian account **email**, your **API token**, and your **site URL** (e.g., `https://myorg.atlassian.net`) +4. Click **Connect** — the app auto-discovers your Jira Cloud ID from the site URL, then validates the credentials against the Jira API +5. On success the integration activates. The API token is encrypted server-side (AES-256-GCM) before storage; the plaintext token is never saved in the browser + +The integration label shows "API Token" and displays the connected site name and URL. + +### Issue Key Detection + +When issue key detection is enabled, the app scans GitHub issue and PR titles (and PR branch names) for Jira issue key patterns (e.g., `PROJ-123`, `TEAM-42`) after each full data refresh. Matched keys are looked up in Jira and displayed as inline badges on the GitHub item rows showing the issue key, status, and a color indicating status category (blue = new, yellow = in progress, green = done). + +Clicking a badge opens the Jira issue in a new tab. + +Toggle: **Settings > Jira Cloud Integration > Auto-detect Jira keys** (visible only when connected). + +Keys must be uppercase (e.g., `PROJ-123`), 2–10 capital letters followed by a dash and a number. Lowercase patterns are not matched. + +### Jira Assigned Tab + +When Jira is connected, a **Jira** tab appears in the tab bar. It shows all open Jira issues assigned to you (via `assignee = currentUser() AND statusCategory != Done`), fetched in the same 5-minute poll cycle as GitHub data. + +**Filters:** Status category (New, In Progress) and priority (Highest through Lowest) filters are available in the filter popover. + +**Grouping:** Issues are grouped by Jira project key, similar to how GitHub items are grouped by repo. + +**Pagination:** Client-side over up to 100 fetched issues. + +**Polling:** Jira issues refresh after each full GitHub poll cycle. There is no hot poll for Jira — issues update every 5 minutes. + +If your Jira token expires (OAuth refresh tokens expire after 90 days of inactivity), a notification prompts you to reconnect in Settings. + +### Bookmarking Jira Issues + +From the Jira Assigned tab, click the **pin icon** on any issue to add it to the Tracked tab alongside your pinned GitHub items. + +Pinned Jira items show the issue key (linked), summary, status, and project group. They are removed automatically from the Tracked tab when the issue no longer appears in your assigned list (i.e., it was reassigned, resolved, or marked Done). + +To unpin manually, use the remove button on the item in the Tracked tab. + +### Disconnecting Jira + +Go to **Settings > Jira Cloud Integration > Disconnect**. This clears all stored Jira credentials and tokens from the browser, disables the Jira tab, and removes Jira issue key detection. Pinned Jira items in the Tracked tab are also cleared. + +### Jira Production Secrets + +For production deployments, provision these Worker secrets via the Wrangler CLI — do not put them in `.env` or `.dev.vars`: + +```bash +wrangler secret put JIRA_CLIENT_ID +wrangler secret put JIRA_CLIENT_SECRET +``` + +Local development uses `.dev.vars` (see `.dev.vars.example`). The Jira Cloud Integration section always appears in Settings. When `VITE_JIRA_CLIENT_ID` is set, both OAuth and API token connection methods are available. When it is absent, only the API token method is shown. + +### Troubleshooting Jira + +**"Reconnect in Settings" notification appears.** +Your OAuth refresh token has expired (90-day inactivity limit) or was revoked. Go to Settings and click **Connect with Jira** to re-authenticate. + +**OAuth button not visible in Settings.** +`VITE_JIRA_CLIENT_ID` is not set or contains an invalid value. Check your `.env` file or deployment configuration. The API token method is always available regardless of this variable. + +**"No Jira Cloud sites found" error after OAuth.** +Your Atlassian account does not have access to any Jira Cloud sites. Confirm your account has at least one Jira site in the Atlassian admin portal. + +**"Could not look up your Jira site" error when connecting via API token.** +The app auto-discovers your Jira Cloud ID from the site URL. This error means the site URL is unreachable or not a valid Jira Cloud instance. Verify the URL is correct (e.g., `https://yourorg.atlassian.net`) and that the site is accessible. + +**Jira badges not appearing on GitHub items.** +Check that **Auto-detect Jira keys** is toggled on in Settings. Keys must appear in issue/PR titles or PR branch names and match the pattern `[A-Z]{2,10}-\d+` exactly (uppercase only). + +**"Access denied" error on the Jira Assigned tab (API token mode).** +Your API token may lack the required permissions, or your account may have been removed from the Jira site. Check your app permissions in Atlassian settings. The dashboard preserves your auth state — you do not need to reconnect unless the token itself is revoked. + +**Jira disconnects across multiple browser tabs.** +Jira uses rotating refresh tokens — each refresh invalidates the previous token. If two tabs attempt a token refresh simultaneously, one may fail and clear auth in all tabs. Refresh the affected tab or reconnect in Settings. This is a rare timing condition that only occurs when tokens expire in multiple tabs at the same instant. + +--- + ## Settings Reference Settings are saved automatically to `localStorage` and persist across sessions. All settings can be exported as a JSON file via **Settings > Data > Export**. @@ -473,19 +594,19 @@ These are UI preferences that persist across sessions but are not included in th The tracker uses GitHub's GraphQL and REST APIs. Each poll cycle consumes some of your 5,000 request hourly budget. Tracking many repos, tracked users, or having a short refresh interval increases consumption. Increasing the refresh interval or reducing the number of tracked repos will reduce API usage. -OAuth tokens and classic PATs use the notifications gate (304 shortcut), which significantly reduces per-cycle cost when nothing has changed. Fine-grained PATs do not support this optimization. +A 60-second events poll uses ETag conditional requests to detect changes at near-zero cost, triggering targeted per-repo refreshes only when needed. For detailed per-source API call counts, see Settings > API Usage. **PAT vs OAuth: what is the difference?** -OAuth tokens (from "Sign in with GitHub") work across all your organizations and support all features including the notifications background-poll optimization. Classic PATs with the correct scopes (`repo`, `read:org`, `notifications`) behave identically to OAuth. +OAuth tokens (from "Sign in with GitHub") work across all your organizations and support all features. Classic PATs with the correct scopes (`repo`, `read:org`) behave identically to OAuth. -Fine-grained PATs are limited to one organization at a time, do not support the `notifications` scope, and therefore cannot use the background-poll optimization — the full poll pauses in hidden tabs, and a warning appears in the notification drawer. +Fine-grained PATs are limited to one organization at a time. Required permissions: Actions (read), Contents (read), Issues (read), Pull requests (read). **Data looks stale after switching back to the tab.** -When a tab has been hidden for more than 2 minutes, a catch-up fetch fires automatically on return. If the notifications gate is unavailable (fine-grained PAT), polling was paused while the tab was hidden — the catch-up fetch provides a single refresh on return. To ensure continuous background updates, use OAuth or a classic PAT with the `notifications` scope. +When a tab has been hidden for more than 2 minutes, a catch-up fetch fires automatically on return. The events poll continues running in background tabs using ETag conditional requests (zero rate-limit cost), so changes are detected even while the tab is hidden. **I want to stop tracking a repository.** diff --git a/mcp/src/data-source.ts b/mcp/src/data-source.ts index 3b28f14d..932e772c 100644 --- a/mcp/src/data-source.ts +++ b/mcp/src/data-source.ts @@ -8,7 +8,9 @@ import { VALID_REPO_NAME } from "../../src/shared/validation.js"; import { METHODS } from "../../src/shared/protocol.js"; import type { Issue, + IssueState, PullRequest, + PullRequestState, WorkflowRun, RepoRef, RateLimitInfo, @@ -131,7 +133,7 @@ function mapSearchItemToPR(item: SearchItem, repoFullName: string): PullRequest id: item.id, number: item.number, title: item.title, - state: item.state, + state: item.state.toUpperCase() as PullRequestState, draft: item.draft ?? false, htmlUrl: item.html_url, createdAt: item.created_at, @@ -163,7 +165,7 @@ function mapSearchItemToIssue(item: SearchItem, repoFullName: string): Issue { id: item.id, number: item.number, title: item.title, - state: item.state, + state: item.state.toUpperCase() as IssueState, htmlUrl: item.html_url, createdAt: item.created_at, updatedAt: item.updated_at, @@ -404,7 +406,7 @@ export class OctokitDataSource implements DataSource { id: raw.id, number: raw.number, title: raw.title, - state: raw.state, + state: raw.state.toUpperCase() as PullRequestState, draft: raw.draft ?? false, htmlUrl: raw.html_url, createdAt: raw.created_at, diff --git a/public/_headers b/public/_headers index 353fe0c6..e0ecdf6e 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,5 @@ /* - Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint + Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y=' https://challenges.cloudflare.com; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com https://api.atlassian.com ws://127.0.0.1:*; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint Reporting-Endpoints: csp-endpoint="/api/csp-report" X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin diff --git a/scripts/validate-deploy.sh b/scripts/validate-deploy.sh index 2b748c5f..0b3cdce2 100755 --- a/scripts/validate-deploy.sh +++ b/scripts/validate-deploy.sh @@ -36,6 +36,7 @@ check_vite_var() { check_vite_var VITE_GITHUB_CLIENT_ID fail "VITE_GITHUB_CLIENT_ID not set (GitHub Actions variable or .env)" check_vite_var VITE_SENTRY_DSN warn "VITE_SENTRY_DSN not set — Sentry disabled in this build" check_vite_var VITE_TURNSTILE_SITE_KEY warn "VITE_TURNSTILE_SITE_KEY not set — Turnstile disabled" +check_vite_var VITE_JIRA_CLIENT_ID warn "VITE_JIRA_CLIENT_ID not set — Jira integration disabled" # ── CF Worker secrets via wrangler ────────────────────────────────────────── if ! WRANGLER=$(resolve_wrangler); then @@ -53,9 +54,11 @@ else has_secret SENTRY_SECURITY_TOKEN || warn "CF Worker secret 'SENTRY_SECURITY_TOKEN' not set — only needed if Sentry Allowed Domains is configured" has_secret SEAL_KEY_NEXT || warn "CF Worker secret 'SEAL_KEY_NEXT' not set — only needed during key rotation" has_secret SESSION_KEY_NEXT || warn "CF Worker secret 'SESSION_KEY_NEXT' not set — only needed during key rotation" + has_secret JIRA_CLIENT_ID || warn "CF Worker secret 'JIRA_CLIENT_ID' not set — Jira integration disabled" + has_secret JIRA_CLIENT_SECRET || warn "CF Worker secret 'JIRA_CLIENT_SECRET' not set — Jira integration disabled" # Detect unexpected secrets not in the known set - KNOWN="ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY TURNSTILE_SECRET_KEY SENTRY_DSN SENTRY_SECURITY_TOKEN SEAL_KEY_NEXT SESSION_KEY_NEXT" + KNOWN="ALLOWED_ORIGIN GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET SESSION_KEY SEAL_KEY TURNSTILE_SECRET_KEY SENTRY_DSN SENTRY_SECURITY_TOKEN SEAL_KEY_NEXT SESSION_KEY_NEXT JIRA_CLIENT_ID JIRA_CLIENT_SECRET" while IFS= read -r secret_name; do found=false for k in $KNOWN; do diff --git a/scripts/waf-smoke-test.sh b/scripts/waf-smoke-test.sh index 2821452d..1911b09a 100755 --- a/scripts/waf-smoke-test.sh +++ b/scripts/waf-smoke-test.sh @@ -24,8 +24,12 @@ BASE="${1:-https://gh.gordoncode.dev}" run_test() { local expected="$1" label="$2" shift 2 - local actual - actual=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "$@") + local actual attempt + for attempt in 1 2 3; do + actual=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "$@") + [[ "$actual" != "000" && "$actual" != "502" && "$actual" != "503" ]] && break + sleep "$attempt" + done if [[ "$actual" == "$expected" ]]; then printf ' PASS [%s] %s\n' "$actual" "$label" else @@ -107,7 +111,7 @@ TESTS=( # --- Run in parallel (::: passes array elements directly, avoiding stdin quoting issues) --- TOTAL=${#TESTS[@]} -OUTPUT=$(parallel --will-cite -k -j2 --delay 0.3 --timeout 15 run_spec ::: "${TESTS[@]}") || true +OUTPUT=$(parallel --will-cite -k -j1 --delay 0.2 --timeout 30 run_spec ::: "${TESTS[@]}") || true # Detect infrastructure failure (parallel crashed, no tests ran) if [[ -z "$OUTPUT" ]]; then diff --git a/src/app/App.tsx b/src/app/App.tsx index a86f133f..d40269f2 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -9,6 +9,7 @@ import { initClientWatcher } from "./services/github"; import { initMcpRelay } from "./lib/mcp-relay"; import LoginPage from "./pages/LoginPage"; import OAuthCallback from "./pages/OAuthCallback"; +import JiraCallback from "./pages/JiraCallback"; import PrivacyPage from "./pages/PrivacyPage"; const DashboardPage = lazy(() => import("./components/dashboard/DashboardPage")); @@ -195,6 +196,7 @@ export default function App() { + } /> } /> } /> diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index dd50fcb7..9c52137a 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { createSignal, createMemo, createEffect, Show, Switch, Match, onMount, onCleanup, untrack } from "solid-js"; +import { createSignal, createMemo, createEffect, on, Show, Switch, Match, onMount, onCleanup, untrack } from "solid-js"; import { createStore, produce, unwrap } from "solid-js/store"; import Header from "../layout/Header"; import TabBar, { TabId } from "../layout/TabBar"; @@ -8,25 +8,32 @@ import IssuesTab from "./IssuesTab"; import PullRequestsTab from "./PullRequestsTab"; import TrackedTab from "./TrackedTab"; import PersonalSummaryStrip from "./PersonalSummaryStrip"; -import { config, setConfig, getCustomTab, isBuiltinTab, type TrackedUser } from "../../stores/config"; -import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; +import { config, setConfig, getCustomTab, isBuiltinTab, updateJiraConfig, type TrackedUser } from "../../stores/config"; +import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; import type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import { fetchOrgs } from "../../services/api"; import { createPollCoordinator, createHotPollCoordinator, + createEventsPollCoordinator, rebuildHotSets, + seedHotSetsFromTargeted, clearHotSets, getHotPollGeneration, fetchAllData, type DashboardData, } from "../../services/poll"; -import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth"; +import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY, jiraAuth, setJiraAuth, isJiraAuthenticated, ensureJiraTokenValid, clearJiraAuth } from "../../stores/auth"; +import { JiraClient, JiraProxyClient, JiraApiError } from "../../services/jira-client"; +import type { JiraIssue } from "../../../shared/jira-types"; +import { detectAndLookupJiraKeys } from "../../services/jira-keys"; +import JiraAssignedTab from "./JiraAssignedTab"; import { updateRelaySnapshot } from "../../lib/mcp-relay"; -import { pushNotification } from "../../lib/errors"; +import { pushNotification, pushError } from "../../lib/errors"; +import { detectNewItems, dispatchNotifications } from "../../lib/notifications"; import { getClient, getGraphqlRateLimit, fetchRateLimitDetails } from "../../services/github"; -import { formatCount, prSizeCategory, rateLimitCssClass } from "../../lib/format"; +import { formatCount, prSizeCategory, rateLimitCssClass, stripParenthetical } from "../../lib/format"; import { setsEqual } from "../../lib/collections"; import { withScrollLock } from "../../lib/scroll"; import { Tooltip } from "../shared/Tooltip"; @@ -123,10 +130,20 @@ export function _resetHasFetchedFresh(value = false) { setHasFetchedFresh(value) const [lastFetchHadErrors, setLastFetchHadErrors] = createSignal(false); +// Jira state — module-level to persist across DashboardPage remounts (e.g., Settings → Dashboard navigation) +const [jiraIssues, setJiraIssues] = createSignal([]); +const [jiraLoading, setJiraLoading] = createSignal(false); +const [jiraKeyMap, setJiraKeyMap] = createSignal>(new Map()); +let _jiraFetching = false; + // Clear dashboard data and stop polling on logout to prevent cross-user data leakage onAuthCleared(() => { resetDashboardData(); setHasFetchedFresh(false); + setJiraIssues([]); + setJiraLoading(false); + setJiraKeyMap(new Map()); + _jiraFetching = false; const coord = _coordinator(); if (coord) { coord.destroy(); @@ -137,6 +154,11 @@ onAuthCleared(() => { hotCoord.destroy(); if (_hotCoordinator() === hotCoord) _setHotCoordinator(null); } + const eventsCoord = _eventsCoordinator(); + if (eventsCoord) { + eventsCoord.destroy(); + if (_eventsCoordinator() === eventsCoord) _setEventsCoordinator(null); + } clearHotSets(); }); @@ -167,101 +189,96 @@ async function pollFetch(): Promise { }); } }); - // When notifications gate says nothing changed, keep existing data - if (!data.skipped) { - const hasErrors = data.errors.length > 0; - setLastFetchHadErrors(hasErrors); - - // When the fetch had errors and returned no data, keep stale dashboard - // visible rather than wiping it to empty. This prevents the summary strip, - // tab counts, and tracked items from vanishing during rate limiting. - if (hasErrors && data.issues.length === 0 && data.pullRequests.length === 0 && data.workflowRuns.length === 0) { - setDashboardData("loading", false); - return data; - } + const hasErrors = data.errors.length > 0; + setLastFetchHadErrors(hasErrors); - setHasFetchedFresh(true); - const now = new Date(); - - if (phaseOneFired) { - // Phase 1 fired — use fine-grained merge for the light→enriched - // transition. Only update heavy fields to avoid re-rendering the - // entire list (light fields haven't changed within this poll cycle). - const enrichedMap = new Map(); - for (const pr of data.pullRequests) enrichedMap.set(pr.id, pr); - - setDashboardData(produce((state) => { - state.issues = data.issues; - state.workflowRuns = data.workflowRuns; - state.loading = false; - state.lastRefreshedAt = now; - - let canMerge = state.pullRequests.length === enrichedMap.size; - if (canMerge) { - for (let i = 0; i < state.pullRequests.length; i++) { - if (!enrichedMap.has(state.pullRequests[i].id)) { canMerge = false; break; } - } + // When the fetch had errors and returned no data, keep stale dashboard + // visible rather than wiping it to empty. This prevents the summary strip, + // tab counts, and tracked items from vanishing during rate limiting. + if (hasErrors && data.issues.length === 0 && data.pullRequests.length === 0 && data.workflowRuns.length === 0) { + setDashboardData("loading", false); + return data; + } + + setHasFetchedFresh(true); + const now = new Date(); + + if (phaseOneFired) { + // Phase 1 fired — use fine-grained merge for the light→enriched + // transition. Only update heavy fields to avoid re-rendering the + // entire list (light fields haven't changed within this poll cycle). + const enrichedMap = new Map(); + for (const pr of data.pullRequests) enrichedMap.set(pr.id, pr); + + setDashboardData(produce((state) => { + state.issues = data.issues; + state.workflowRuns = data.workflowRuns; + state.loading = false; + state.lastRefreshedAt = now; + + let canMerge = state.pullRequests.length === enrichedMap.size; + if (canMerge) { + for (let i = 0; i < state.pullRequests.length; i++) { + if (!enrichedMap.has(state.pullRequests[i].id)) { canMerge = false; break; } } + } - if (canMerge) { - for (let i = 0; i < state.pullRequests.length; i++) { - const e = enrichedMap.get(state.pullRequests[i].id)!; - const pr = state.pullRequests[i]; - pr.headSha = e.headSha; - pr.assigneeLogins = e.assigneeLogins; - pr.reviewerLogins = e.reviewerLogins; - pr.checkStatus = e.checkStatus; - pr.additions = e.additions; - pr.deletions = e.deletions; - pr.changedFiles = e.changedFiles; - pr.comments = e.comments; - pr.reviewThreads = e.reviewThreads; - pr.totalReviewCount = e.totalReviewCount; - pr.enriched = e.enriched; - pr.nodeId = e.nodeId; - pr.surfacedBy = e.surfacedBy; - pr.starCount = e.starCount; - } - } else { - state.pullRequests = data.pullRequests; + if (canMerge) { + for (let i = 0; i < state.pullRequests.length; i++) { + const e = enrichedMap.get(state.pullRequests[i].id)!; + const pr = state.pullRequests[i]; + pr.headSha = e.headSha; + pr.assigneeLogins = e.assigneeLogins; + pr.reviewerLogins = e.reviewerLogins; + pr.checkStatus = e.checkStatus; + pr.additions = e.additions; + pr.deletions = e.deletions; + pr.changedFiles = e.changedFiles; + pr.comments = e.comments; + pr.reviewThreads = e.reviewThreads; + pr.totalReviewCount = e.totalReviewCount; + pr.enriched = e.enriched; + pr.nodeId = e.nodeId; + pr.surfacedBy = e.surfacedBy; + pr.starCount = e.starCount; } - })); - } else { - // Phase 1 did NOT fire (cached data existed or subsequent poll). - // Full atomic replacement — all fields (light + heavy) may have - // changed since the last cycle. Preserve scroll position: SolidJS - // DOM updates are synchronous within the setter, so save/restore - // around it to prevent scroll reset from DOM rebuild. - withScrollLock(() => { - setDashboardData({ - issues: data.issues, - pullRequests: data.pullRequests, - workflowRuns: data.workflowRuns, - loading: false, - lastRefreshedAt: now, - }); - }); - } - rebuildHotSets(data); - // Persist for stale-while-revalidate on full page reload. - // Errors are transient and not persisted. Deferred to avoid blocking paint. - const cachePayload = { - _v: CACHE_VERSION, - issues: data.issues, - pullRequests: data.pullRequests, - workflowRuns: data.workflowRuns, - lastRefreshedAt: now.toISOString(), - }; - setTimeout(() => { - try { - localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(cachePayload)); - } catch { - pushNotification("localStorage:dashboard", "Dashboard cache write failed — storage may be full", "warning"); + } else { + state.pullRequests = data.pullRequests; } - }, 0); + })); } else { - setDashboardData("loading", false); + // Phase 1 did NOT fire (cached data existed or subsequent poll). + // Full atomic replacement — all fields (light + heavy) may have + // changed since the last cycle. Preserve scroll position: SolidJS + // DOM updates are synchronous within the setter, so save/restore + // around it to prevent scroll reset from DOM rebuild. + withScrollLock(() => { + setDashboardData({ + issues: data.issues, + pullRequests: data.pullRequests, + workflowRuns: data.workflowRuns, + loading: false, + lastRefreshedAt: now, + }); + }); } + rebuildHotSets(data); + // Persist for stale-while-revalidate on full page reload. + // Errors are transient and not persisted. Deferred to avoid blocking paint. + const cachePayload = { + _v: CACHE_VERSION, + issues: data.issues, + pullRequests: data.pullRequests, + workflowRuns: data.workflowRuns, + lastRefreshedAt: now.toISOString(), + }; + setTimeout(() => { + try { + localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(cachePayload)); + } catch { + pushNotification("localStorage:dashboard", "Dashboard cache write failed — storage may be full", "warning"); + } + }, 0); return data; } catch (err) { // Handle 401 auth errors @@ -285,12 +302,213 @@ async function pollFetch(): Promise { const [_coordinator, _setCoordinator] = createSignal | null>(null); const [_hotCoordinator, _setHotCoordinator] = createSignal<{ destroy: () => void } | null>(null); +const [_eventsCoordinator, _setEventsCoordinator] = createSignal<{ destroy: () => void } | null>(null); + +// Mutates data.issues[].surfacedBy and data.pullRequests[].surfacedBy in-place before merging. +function handleTargetedData(data: DashboardData, affectedRepos: string[]): void { + const affectedSet = new Set(affectedRepos.map(r => r.toLowerCase())); + + // Build surfacedBy index from old store items BEFORE the merge + const oldSurfacedByIssues = new Map(); + for (const i of dashboardData.issues) { + if (affectedSet.has(i.repoFullName.toLowerCase()) && i.surfacedBy?.length) { + oldSurfacedByIssues.set(i.id, i.surfacedBy); + } + } + const oldSurfacedByPRs = new Map(); + for (const pr of dashboardData.pullRequests) { + if (affectedSet.has(pr.repoFullName.toLowerCase()) && pr.surfacedBy?.length) { + oldSurfacedByPRs.set(pr.id, pr.surfacedBy); + } + } + + // Merge surfacedBy into targeted results before appending + for (const item of data.issues) { + const oldSb = oldSurfacedByIssues.get(item.id); + if (oldSb) { + item.surfacedBy = [...new Set([...(item.surfacedBy ?? []), ...oldSb])]; + } + } + for (const pr of data.pullRequests) { + const oldSb = oldSurfacedByPRs.get(pr.id); + if (oldSb) { + pr.surfacedBy = [...new Set([...(pr.surfacedBy ?? []), ...oldSb])]; + } + } + + withScrollLock(() => { + setDashboardData(produce((state) => { + // ID-based merge: replace targeted items, keep unaffected + tracked-user-only items + const newIssueIds = new Set(data.issues.map(i => i.id)); + state.issues = [ + ...state.issues.filter(i => + !affectedSet.has(i.repoFullName.toLowerCase()) || + !newIssueIds.has(i.id) + ), + ...data.issues, + ]; + const newPRIds = new Set(data.pullRequests.map(pr => pr.id)); + state.pullRequests = [ + ...state.pullRequests.filter(pr => + !affectedSet.has(pr.repoFullName.toLowerCase()) || + !newPRIds.has(pr.id) + ), + ...data.pullRequests, + ]; + const newRunIds = new Set(data.workflowRuns.map(r => r.id)); + state.workflowRuns = [ + ...state.workflowRuns.filter(r => + !affectedSet.has(r.repoFullName.toLowerCase()) || + !newRunIds.has(r.id) + ), + ...data.workflowRuns, + ]; + })); + }); + + for (const err of data.errors) { + pushError(err.repo, err.message, err.retryable); + } + + const newItems = detectNewItems(data); + dispatchNotifications(newItems, config); + + seedHotSetsFromTargeted(data); + + // Capture snapshot eagerly — a concurrent hot poll can mutate the store via produce + // between here and the deferred setTimeout write. + const lastRefreshed = dashboardData.lastRefreshedAt; + const snapshot = { + _v: CACHE_VERSION, + issues: [...dashboardData.issues], + pullRequests: [...dashboardData.pullRequests], + workflowRuns: [...dashboardData.workflowRuns], + lastRefreshedAt: lastRefreshed?.toISOString() ?? null, + }; + setTimeout(() => { + try { + localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(snapshot)); + } catch { + // Non-fatal + } + }, 0); +} export default function DashboardPage() { const [hotPollingPRIds, setHotPollingPRIds] = createSignal>(new Set()); const [hotPollingRunIds, setHotPollingRunIds] = createSignal>(new Set()); const [rlDetail, setRlDetail] = createSignal("Loading..."); + // Narrow reactivity: extract authMethod so unrelated jira config changes don't recreate the client + const jiraAuthMethod = createMemo(() => config.jira?.authMethod); + const jiraClient = createMemo(() => { + const auth = jiraAuth(); + const method = jiraAuthMethod(); + if (!auth) return null; + if (method === "token") { + if (!auth.email) return null; + return new JiraProxyClient(auth.cloudId, auth.email, auth.accessToken, (resealed) => { + const cur = jiraAuth(); + if (cur) setJiraAuth({ ...cur, accessToken: resealed }); + }); + } + return new JiraClient(auth.cloudId, async () => { + await ensureJiraTokenValid(); + const currentAuth = jiraAuth(); + if (!currentAuth) throw new Error("Jira auth cleared during token refresh"); + return currentAuth.accessToken; + }); + }); + + function jiraJqlForScope(scope: string): string { + const field = scope === "reported" ? "reporter" : scope === "watching" ? "watcher" : "assignee"; + return `${field} = currentUser() AND statusCategory != Done ORDER BY priority DESC`; + } + + async function fetchJiraAssigned(): Promise { + if (_jiraFetching) return; + const client = jiraClient(); + if (!client) return; + _jiraFetching = true; + setJiraLoading(true); + try { + const scope = viewState.tabFilters.jiraAssigned?.scope ?? "assigned"; + const result = await client.searchJql( + jiraJqlForScope(scope), + { maxResults: 100 } + ); + if (import.meta.env.DEV) { + const missing = result.issues.filter((i) => !i.fields.issuetype); + if (missing.length > 0) console.info("[jira] issues missing issuetype:", missing.map((i) => `${i.key}: ${JSON.stringify(i.fields.issuetype)}`)); + } + if (!isJiraAuthenticated()) return; + setJiraIssues(result.issues); + + // Auto-prune tracked Jira items that are done or deleted (scope-independent). + // Resolves status from current search results first, then bulkFetches only + // keys not covered (items from a different scope than the current view). + if (config.enableTracking && viewState.trackedItems.length > 0) { + const trackedJiraKeys = viewState.trackedItems + .filter((item) => item.source === "jira" && item.jiraKey) + .map((item) => item.jiraKey!); + if (trackedJiraKeys.length > 0) { + try { + const liveKeys = new Map(); + for (const issue of result.issues) { + liveKeys.set(issue.key, issue.fields.status.statusCategory.key); + } + const uncheckedKeys = trackedJiraKeys.filter((k) => !liveKeys.has(k)); + const errorKeys = new Set(); + if (uncheckedKeys.length > 0) { + const bulkResult = await client.bulkFetch(uncheckedKeys, ["status"]); + for (const issue of bulkResult.issues) { + liveKeys.set(issue.key, issue.fields.status.statusCategory.key); + } + for (const err of bulkResult.errors ?? []) { + for (const key of err.issueIdsOrKeys) errorKeys.add(key); + } + } + for (const jiraKey of trackedJiraKeys) { + if (errorKeys.has(jiraKey)) continue; + const statusCat = liveKeys.get(jiraKey); + if (!statusCat || statusCat === "done") { + untrackJiraItem(jiraKey); + } + } + } catch { + // Prune errors must not break the main fetch cycle + } + } + } + } catch (err) { + if (err instanceof JiraApiError) { + if (err.status === 401) { + clearJiraAuth(); + pushNotification("jira", "Jira session expired — please reconnect in Settings", "warning"); + } else if (err.status === 403) { + pushNotification("jira", "Jira: access denied — check your app permissions or site access in Atlassian settings", "warning"); + } else { + pushNotification("jira", "Jira fetch failed — will retry on next refresh", "warning"); + } + } else { + pushNotification("jira", "Jira: unexpected error — will retry on next refresh", "warning"); + } + } finally { + _jiraFetching = false; + setJiraLoading(false); + } + } + + // CRITICAL: must never throw or rethrow — Jira errors must not break GitHub poll cycle + function handleJiraError(err: unknown): void { + try { + console.warn("[jira] poll error:", err); + pushNotification("jira", "Jira: unexpected error — will retry on next refresh", "warning"); + } catch { + // final catch-all — never propagate + } + } + function fetchAndSetRlDetail(): void { void fetchRateLimitDetails().then((detail) => { if (!detail) { @@ -325,6 +543,7 @@ export default function DashboardPage() { function resolveInitialTab(): TabId { const tab = config.rememberLastTab ? viewState.lastActiveTab : config.defaultTab; if (tab === "tracked" && !config.enableTracking) return "issues"; + if (tab === "jiraAssigned" && !config.jira?.enabled) return "issues"; // Validate custom tab still exists; fall back to "issues" if stale if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return "issues"; return tab; @@ -355,6 +574,21 @@ export default function DashboardPage() { } }); + // Redirect away from Jira tab when Jira is disabled at runtime + createEffect(() => { + if (!config.jira?.enabled && activeTab() === "jiraAssigned") { + handleTabChange("issues"); + } + }); + + // Clear stale Jira data when auth is cleared (e.g., 401 during token refresh) + createEffect(() => { + if (!isJiraAuthenticated()) { + setJiraIssues([]); + setJiraKeyMap(new Map()); + } + }); + // Redirect away from a custom tab that was deleted while active createEffect(() => { const tab = activeTab(); @@ -392,6 +626,7 @@ export default function DashboardPage() { const pruneKeys = new Set(); for (const item of viewState.trackedItems) { + if (item.source === "jira") continue; // explicit guard — Jira items handled separately if (!polledRepos.has(item.repoFullName)) continue; // repo deselected — keep item const isLive = item.type === "issue" ? liveIssueIds.has(item.id) : livePrIds.has(item.id); if (!isLive) pruneKeys.add(`${item.type}:${item.id}`); @@ -404,6 +639,21 @@ export default function DashboardPage() { _setCoordinator(createPollCoordinator(() => config.refreshInterval, pollFetch)); } + if (!_eventsCoordinator()) { + _setEventsCoordinator(createEventsPollCoordinator( + () => user()?.login ?? "", + () => { + const repos = new Set(); + for (const r of [...config.selectedRepos, ...(config.upstreamRepos ?? []), ...(config.monitoredRepos ?? [])]) { + repos.add(`${r.owner}/${r.name}`.toLowerCase()); + } + return repos; + }, + () => _coordinator()?.isRefreshing() ?? false, + handleTargetedData, + )); + } + if (!_hotCoordinator()) { _setHotCoordinator(createHotPollCoordinator( () => config.hotPollInterval, @@ -413,6 +663,11 @@ export default function DashboardPage() { // If a full refresh completed during the fetch, _hotPollGeneration will have // been incremented by rebuildHotSets(), and fetchGeneration will be stale. if (fetchGeneration !== getHotPollGeneration()) return; // stale, discard + const terminalPrIds = new Set(); + for (const [prId, update] of prUpdates) { + const s = update.state; + if (s === "CLOSED" || s === "MERGED") terminalPrIds.add(prId); + } setDashboardData(produce((state) => { // Apply PR status updates for (const pr of state.pullRequests) { @@ -431,7 +686,27 @@ export default function DashboardPage() { run.updatedAt = update.updatedAt; run.completedAt = update.completedAt; } + if (terminalPrIds.size > 0) { + state.pullRequests = state.pullRequests.filter((pr) => !terminalPrIds.has(pr.id)); + } })); + if (terminalPrIds.size > 0) { + console.info(`[hot-poll] Spliced ${terminalPrIds.size} terminal PR(s) from store`); + setTimeout(() => { + try { + const cachePayload = { + _v: CACHE_VERSION, + issues: dashboardData.issues, + pullRequests: dashboardData.pullRequests, + workflowRuns: dashboardData.workflowRuns, + lastRefreshedAt: dashboardData.lastRefreshedAt?.toISOString(), + }; + localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(cachePayload)); + } catch { + pushNotification("localStorage:dashboard", "Dashboard cache write failed — storage may be full", "warning"); + } + }, 0); + } // Prune tracked PRs that became closed/merged via hot poll. // The auto-prune createEffect only fires when the pullRequests array // reference changes (full refresh). Hot poll mutates nested pr.state @@ -439,8 +714,7 @@ export default function DashboardPage() { if (config.enableTracking && viewState.trackedItems.length > 0 && prUpdates.size > 0) { const pruneKeys = new Set(); for (const [prId, update] of prUpdates) { - const stateVal = update.state?.toUpperCase(); - if (stateVal === "CLOSED" || stateVal === "MERGED") { + if (update.state === "CLOSED" || update.state === "MERGED") { if (viewState.trackedItems.some(t => t.type === "pullRequest" && t.id === prId)) { pruneKeys.add(`pullRequest:${prId}`); } @@ -481,16 +755,32 @@ export default function DashboardPage() { }); } + // Backfill config.jira.siteUrl from auth state if missing (handles configs + // saved before siteUrl was added, or OAuth setups that stored it only in auth) + if (config.jira?.enabled && !config.jira.siteUrl) { + const auth = jiraAuth(); + if (auth?.siteUrl) updateJiraConfig({ siteUrl: auth.siteUrl }); + } + + // Immediate Jira fetch on mount — the deferred lastRefreshAt effect only + // fires on the NEXT poll, leaving a gap where jiraIssues may be empty. + if (config.jira?.enabled && isJiraAuthenticated()) { + fetchJiraAssigned().catch(handleJiraError); + } + // Wall-clock tick keeps relative time displays fresh between full poll cycles. const clockInterval = setInterval(() => setClockTick((t) => t + 1), 60_000); onCleanup(() => { const coord = _coordinator(); const hotCoord = _hotCoordinator(); + const eventsCoord = _eventsCoordinator(); coord?.destroy(); if (_coordinator() === coord) _setCoordinator(null); hotCoord?.destroy(); if (_hotCoordinator() === hotCoord) _setHotCoordinator(null); + eventsCoord?.destroy(); + if (_eventsCoordinator() === eventsCoord) _setEventsCoordinator(null); clearHotSets(); clearInterval(clockInterval); }); @@ -556,13 +846,13 @@ export default function DashboardPage() { // Visible data for built-in tabs — filters out exclusively-owned items const visibleIssues = createMemo(() => { const map = exclusiveOwnership().issues; - if (map.size === 0) return dashboardData.issues; - return dashboardData.issues.filter((i) => isItemVisibleOnTab(map, i.id, "issues")); + if (map.size === 0) return dashboardData.issues.filter((i) => i.state === "OPEN"); + return dashboardData.issues.filter((i) => i.state === "OPEN" && isItemVisibleOnTab(map, i.id, "issues")); }); const visiblePullRequests = createMemo(() => { const map = exclusiveOwnership().pullRequests; - if (map.size === 0) return dashboardData.pullRequests; - return dashboardData.pullRequests.filter((p) => isItemVisibleOnTab(map, p.id, "pullRequests")); + if (map.size === 0) return dashboardData.pullRequests.filter((p) => p.state === "OPEN"); + return dashboardData.pullRequests.filter((p) => p.state === "OPEN" && isItemVisibleOnTab(map, p.id, "pullRequests")); }); const visibleWorkflowRuns = createMemo(() => { const map = exclusiveOwnership().actions; @@ -606,6 +896,7 @@ export default function DashboardPage() { preset, resolveLogin: login, }); customCounts[tab.id] = data.issues.filter((i) => { + if (i.state !== "OPEN") return false; if (!isItemVisibleOnTab(ownership.issues, i.id, tab.id)) return false; if (!isIssueVisible(i, { ignoredIds: ignoredIssues, hideDepDashboard: viewState.hideDepDashboard, globalFilter: null })) return false; if (f.scope === "involves_me" && !isUserInvolved(i, login, monitoredSet)) return false; @@ -627,6 +918,7 @@ export default function DashboardPage() { preset, resolveLogin: login, }); customCounts[tab.id] = data.pullRequests.filter((p) => { + if (p.state !== "OPEN") return false; if (!isItemVisibleOnTab(ownership.pullRequests, p.id, tab.id)) return false; if (!isPrVisible(p, { ignoredIds: ignoredPRs, globalFilter: null })) return false; if (f.scope === "involves_me" && !isUserInvolved(p, login, monitoredSet, p.enriched !== false ? p.reviewerLogins : undefined)) return false; @@ -692,6 +984,14 @@ export default function DashboardPage() { isRunVisible(w, { ignoredIds: ignoredRuns, showPrRuns: viewState.showPrRuns, globalFilter: builtinFilter }) ).length, ...(config.enableTracking ? { tracked: viewState.trackedItems.length } : {}), + ...(config.jira?.enabled ? (() => { + const f = viewState.tabFilters.jiraAssigned; + return { jiraAssigned: jiraIssues().filter((issue) => { + if (f?.statusCategory !== "all" && issue.fields.status.statusCategory.key !== f?.statusCategory) return false; + if (f?.priority !== "all" && stripParenthetical(issue.fields.priority?.name ?? "") !== f?.priority) return false; + return true; + }).length }; + })() : {}), ...customCounts, }; }); @@ -717,6 +1017,54 @@ export default function DashboardPage() { return getCustomTab(id) ?? null; }); + // Fingerprint of issue+PR titles — changes only when titles actually change, not on + // every lastRefreshedAt tick (e.g., hot poll, clock tick, workflow run updates). + const titleFingerprint = createMemo(() => { + const issueTitles = dashboardData.issues.map((i) => i.title).join("\0"); + const prTitles = dashboardData.pullRequests.map((p) => `${p.title}\0${p.headRef ?? ""}`).join("\0"); + return `${issueTitles}|||${prTitles}`; + }); + + // Jira key detection runs only when titles change — NOT on every lastRefreshedAt tick. + // Guards: jira enabled+detection, authenticated, client ready, and titles actually changed. + createEffect(on(titleFingerprint, () => { + if (import.meta.env.DEV) console.info("[jira] key detection guard:", { enabled: config.jira?.enabled, detection: config.jira?.issueKeyDetection, auth: isJiraAuthenticated(), client: !!jiraClient(), refreshed: !!dashboardData.lastRefreshedAt }); + if (!config.jira?.enabled || !config.jira?.issueKeyDetection) return; + if (!isJiraAuthenticated()) return; + const client = jiraClient(); + if (!client) return; + if (!dashboardData.lastRefreshedAt) return; + const items = [ + ...dashboardData.issues.map((i) => ({ title: i.title })), + ...dashboardData.pullRequests.map((p) => ({ title: p.title, headRef: p.headRef })), + ]; + void detectAndLookupJiraKeys(items, client).then((map) => { + if (import.meta.env.DEV) console.info("[jira] key detection:", map.size, "keys found", [...map.keys()].slice(0, 10)); + setJiraKeyMap(map); + }).catch(handleJiraError); + })); + + // Jira assigned issues poll: fires after each GitHub full refresh cycle + createEffect(on( + () => _coordinator()?.lastRefreshAt(), + () => { + if (!config.jira?.enabled || !isJiraAuthenticated()) return; + fetchJiraAssigned().catch(handleJiraError); + }, + { defer: true } + )); + + // Re-fetch when Jira scope filter changes (assigned/reported/watching) + createEffect(on( + () => viewState.tabFilters.jiraAssigned?.scope, + () => { + if (!config.jira?.enabled || !isJiraAuthenticated()) return; + setJiraIssues([]); + fetchJiraAssigned().catch(handleJiraError); + }, + { defer: true } + )); + // Push dashboard data into the MCP relay snapshot on each full refresh. // Tracks lastRefreshedAt (always updated alongside data arrays in pollFetch). // Hot poll updates are intentionally excluded — relay reflects full-refresh data only. @@ -756,6 +1104,7 @@ export default function DashboardPage() { onTabChange={handleTabChange} counts={tabCounts()} enableTracking={config.enableTracking} + enableJira={!!config.jira?.enabled} customTabs={config.customTabs.map((t) => ({ id: t.id, name: t.name }))} onAddTab={() => setShowCustomTabModal(true)} onEditTab={(id) => { setEditingTabId(id); setShowCustomTabModal(true); }} @@ -785,6 +1134,7 @@ export default function DashboardPage() { monitoredRepos={config.monitoredRepos} configRepoNames={configRepoNames()} refreshTick={refreshTick()} + jiraKeyMap={jiraKeyMap} /> @@ -798,6 +1148,7 @@ export default function DashboardPage() { monitoredRepos={config.monitoredRepos} configRepoNames={configRepoNames()} refreshTick={refreshTick()} + jiraKeyMap={jiraKeyMap} /> @@ -810,6 +1161,13 @@ export default function DashboardPage() { hotPollingPRIds={hotPollingPRIds()} /> + + + @@ -862,6 +1221,7 @@ export default function DashboardPage() { refreshTick={refreshTick()} customTabId={tab().id} filterPreset={tab().filterPreset} + jiraKeyMap={jiraKeyMap} /> diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 07917b4e..a2b3e125 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -21,6 +21,8 @@ import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; import EmptyLockedRepoRow from "../shared/EmptyLockedRepoRow"; import { Tooltip } from "../shared/Tooltip"; +import JiraBadge from "../shared/JiraBadge"; +import { extractJiraKeys } from "../../../shared/validation"; export interface IssuesTabProps { issues: Issue[]; @@ -33,6 +35,7 @@ export interface IssuesTabProps { refreshTick?: number; customTabId?: string; filterPreset?: Record; + jiraKeyMap?: () => ReadonlyMap; } type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "comments"; @@ -120,6 +123,7 @@ export default function IssuesTab(props: IssuesTabProps) { const meta = new Map }>(); let items = props.issues.filter((issue) => { + if (issue.state !== "OPEN") return false; if (!isIssueVisible(issue, { ignoredIds, hideDepDashboard: viewState.hideDepDashboard, globalFilter })) return false; const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, [], upstreamRepoSet().has(issue.repoFullName)); @@ -252,7 +256,7 @@ export default function IssuesTab(props: IssuesTabProps) { if (trackedIssueIds().has(issue.id)) { untrackItem(issue.id, "issue"); } else { - trackItem({ id: issue.id, number: issue.number, type: "issue", repoFullName: issue.repoFullName, title: issue.title, addedAt: Date.now() }); + trackItem({ id: issue.id, number: issue.number, type: "issue", source: "github", repoFullName: issue.repoFullName, title: issue.title, addedAt: Date.now() }); } } @@ -430,6 +434,18 @@ export default function IssuesTab(props: IssuesTabProps) { } > + + + {(key) => ( + + )} + + )} diff --git a/src/app/components/dashboard/JiraAssignedTab.tsx b/src/app/components/dashboard/JiraAssignedTab.tsx new file mode 100644 index 00000000..e58e3ae3 --- /dev/null +++ b/src/app/components/dashboard/JiraAssignedTab.tsx @@ -0,0 +1,479 @@ +import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; +import type { JiraIssue } from "../../../shared/jira-types"; +import { viewState, setTabFilter, resetAllTabFilters, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded } from "../../stores/view"; +import { config } from "../../stores/config"; +import { jiraStatusCategoryClass, stripParenthetical } from "../../lib/format"; +import { isSafeJiraSiteUrl } from "../../lib/url"; +import { groupByRepo, computePageLayout, slicePageGroups, ensureLockedRepoGroups, orderRepoGroups } from "../../lib/grouping"; +import PaginationControls from "../shared/PaginationControls"; +import FilterPopover from "../shared/FilterPopover"; +import LoadingSpinner from "../shared/LoadingSpinner"; +import SortDropdown, { type SortOption } from "../shared/SortDropdown"; +import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; +import ChevronIcon from "../shared/ChevronIcon"; +import RepoLockControls from "../shared/RepoLockControls"; +import { Tooltip } from "../shared/Tooltip"; + +const JIRA_FILTER_DEFAULTS = JiraFiltersSchema.parse({}); +const ITEMS_PER_PAGE = 25; +const TAB_KEY = "jiraAssigned"; + +interface JiraAssignedTabProps { + issues: JiraIssue[]; + loading: boolean; + siteUrl: string; +} + +const SCOPE_OPTIONS = [ + { value: "assigned", label: "Assigned to me" }, + { value: "reported", label: "Created by me" }, + { value: "watching", label: "Watching" }, +]; + +const STATUS_CATEGORY_OPTIONS = [ + { value: "all", label: "All" }, + { value: "new", label: "To Do" }, + { value: "indeterminate", label: "In Progress" }, +]; + +const PRIORITY_OPTIONS = [ + { value: "all", label: "All" }, + { value: "Highest", label: "Highest" }, + { value: "High", label: "High" }, + { value: "Medium", label: "Medium" }, + { value: "Low", label: "Low" }, + { value: "Lowest", label: "Lowest" }, +]; + +const JIRA_SORT_OPTIONS: SortOption[] = [ + { label: "Priority", field: "priority", type: "priority", preferredDirection: "asc" }, + { label: "Status", field: "status", type: "status", preferredDirection: "asc" }, + { label: "Key", field: "key", type: "text", preferredDirection: "asc" }, + { label: "Updated", field: "updated", type: "date" }, + { label: "Created", field: "created", type: "date" }, + { label: "Title", field: "title", type: "text", preferredDirection: "asc" }, +]; + +const PRIORITY_ORDER = Object.assign(Object.create(null) as Record, { + Highest: 0, High: 1, Medium: 2, Low: 3, Lowest: 4, +}); + +const STATUS_CATEGORY_ORDER = Object.assign(Object.create(null) as Record, { + new: 0, indeterminate: 1, done: 2, +}); + +// Sub-ordering for indeterminate statuses based on SDLC progression. +// Derived from Red Hat Jira MGMT project workflows + common patterns. +// Unknown statuses get FALLBACK_STATUS_ORDER and sort alphabetically among themselves. +const FALLBACK_STATUS_ORDER = 4; +const STATUS_SDLC_ORDER: Record = Object.assign(Object.create(null) as Record, { + "ASSIGNED": 0, "Selected for Development": 0, "Selected to Development": 0, + "In Progress": 1, "In Development": 1, "Dev In Progress": 1, "Development": 1, + "Coding In Progress": 1, "Work in progress": 1, "Implementation": 1, + "Code Review": 2, "Peer Review": 2, "In Review": 2, "Review": 2, + "Ready for Review": 2, "PR Opened": 2, "Needs Peer Review": 2, + "Needs Review": 2, "Under Review": 2, "Ready For Review": 2, + "Dev Complete": 3, "Development Complete": 3, "Feature Complete": 3, "MODIFIED": 3, "Merged": 3, + "ON_QA": 5, "QA": 5, "In QA": 5, "QA In Progress": 5, "In Test": 5, + "Testing": 5, "Ready for QA": 5, "Ready for QE": 5, "QE InProgress": 5, + "In Testing": 5, "QA READY": 5, "QE Verification": 5, + "Approved": 6, "Pending Approval": 6, "PM Approved": 6, "Story Approved": 6, "POST": 6, + "Ready for Release": 7, "Release Pending": 7, "Preparing Release": 7, + "Push Ready": 7, "Ready to Release": 7, "Ready For Release": 7, + "Blocked": 8, "On Hold/Blocked": 8, "Blocked External": 8, "ENG BLOCKED": 8, + "Stalled / Blocked": 8, "Blocked/On Hold": 8, "QA Blocked": 8, +}); + +let _jiraExpandInitialized = false; + +export function _resetJiraTabState() { + _jiraExpandInitialized = false; +} + +const ISSUE_TYPE_ICONS: Record = Object.assign( + Object.create(null) as Record, + { + Epic: { path: "M13 3L4 14h5l-2 7 9-11h-5l2-7z", color: "#904ee2" }, + Story: { path: "M4 4h16v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h12V4H6z", color: "#63ba3c" }, + Task: { path: "M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z", color: "#4bade8" }, + Bug: { path: "M12 2a8 8 0 100 16 8 8 0 000-16zm0 14a6 6 0 110-12 6 6 0 010 12zm-1-5h2V7h-2v4zm0 2h2v2h-2v-2z", color: "#e5493a" }, + Subtask: { path: "M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z", color: "#4bade8" }, + }, +); + +function IssueTypeFallbackIcon(props: { name: string }) { + const normalized = () => stripParenthetical(props.name); + const icon = () => ISSUE_TYPE_ICONS[normalized()]; + return ( + {normalized()} + } + > + {(i) => ( + + + + )} + + ); +} + +export default function JiraAssignedTab(props: JiraAssignedTabProps) { + const [page, setPage] = createSignal(0); + + const filters = createMemo(() => viewState.tabFilters.jiraAssigned ?? JIRA_FILTER_DEFAULTS); + + const pinnedJiraKeys = createMemo(() => + new Set( + viewState.trackedItems + .filter((t) => t.source === "jira" && t.jiraKey) + .map((t) => t.jiraKey!) + ) + ); + + const filtered = createMemo(() => { + const f = filters(); + return props.issues.filter((issue) => { + if (f.statusCategory !== "all" && issue.fields.status.statusCategory.key !== f.statusCategory) return false; + if (f.priority !== "all" && stripParenthetical(issue.fields.priority?.name ?? "") !== f.priority) return false; + return true; + }); + }); + + const filteredSorted = createMemo(() => { + const items = [...filtered()]; + const field = filters().sortField; + const dir = filters().sortDirection; + items.sort((a, b) => { + let cmp = 0; + switch (field) { + case "priority": + cmp = (PRIORITY_ORDER[stripParenthetical(a.fields.priority?.name ?? "Medium")] ?? 2) + - (PRIORITY_ORDER[stripParenthetical(b.fields.priority?.name ?? "Medium")] ?? 2); + break; + case "status": { + const aCat = STATUS_CATEGORY_ORDER[a.fields.status.statusCategory.key] ?? 1; + const bCat = STATUS_CATEGORY_ORDER[b.fields.status.statusCategory.key] ?? 1; + cmp = aCat - bCat; + if (cmp === 0) { + const aSub = STATUS_SDLC_ORDER[a.fields.status.name] ?? FALLBACK_STATUS_ORDER; + const bSub = STATUS_SDLC_ORDER[b.fields.status.name] ?? FALLBACK_STATUS_ORDER; + cmp = aSub - bSub; + if (cmp === 0) cmp = a.fields.status.name.localeCompare(b.fields.status.name); + } + break; + } + case "key": { + const aP = a.key.replace(/-\d+$/, ""); + const bP = b.key.replace(/-\d+$/, ""); + cmp = aP === bP + ? parseInt(a.key.split("-").pop()!, 10) - parseInt(b.key.split("-").pop()!, 10) + : aP.localeCompare(bP); + break; + } + case "updated": { + const aUp = String(a.fields.updated ?? ""); + const bUp = String(b.fields.updated ?? ""); + cmp = aUp < bUp ? -1 : aUp > bUp ? 1 : 0; + break; + } + case "created": { + const aCr = String(a.fields.created ?? ""); + const bCr = String(b.fields.created ?? ""); + cmp = aCr < bCr ? -1 : aCr > bCr ? 1 : 0; + break; + } + case "title": + cmp = a.fields.summary.localeCompare(b.fields.summary); + break; + default: + break; + } + return dir === "asc" ? cmp : -cmp; + }); + return items; + }); + + type JiraItem = JiraIssue & { repoFullName: string }; + const itemsWithGroupKey = createMemo(() => + filteredSorted().map((issue): JiraItem => ({ + ...issue, + repoFullName: issue.fields.project?.key ?? "OTHER", + })) + ); + + const repoGroups = createMemo(() => { + const groups = groupByRepo(itemsWithGroupKey()); + const lockedForTab = viewState.lockedRepos[TAB_KEY] ?? []; + const withLocked = ensureLockedRepoGroups( + groups, + lockedForTab, + (name) => ({ repoFullName: name, items: [] as JiraItem[] }), + ); + return orderRepoGroups(withLocked, lockedForTab); + }); + + const pageLayout = createMemo(() => computePageLayout(repoGroups(), ITEMS_PER_PAGE)); + const pageCount = createMemo(() => pageLayout().pageCount); + const pageGroups = createMemo(() => + slicePageGroups(repoGroups(), pageLayout().boundaries, pageCount(), page()) + ); + + const projectKeys = createMemo(() => repoGroups().map((g) => g.repoFullName)); + + createEffect(() => { + const max = pageCount() - 1; + if (page() > max) setPage(max); + }); + + createEffect(() => { + const keys = projectKeys(); + if (keys.length === 0 || _jiraExpandInitialized) return; + const expanded = viewState.expandedRepos[TAB_KEY]; + if (expanded && Object.keys(expanded).length > 0) return; + _jiraExpandInitialized = true; + setAllExpanded(TAB_KEY, keys, true); + }); + + return ( +
+ {/* Filter + sort toolbar */} +
+ { + setTabFilter("jiraAssigned", field as "scope", value); + setPage(0); + }} + /> + | + Filter: + { + setTabFilter("jiraAssigned", field as "statusCategory", value); + setPage(0); + }} + /> + { + setTabFilter("jiraAssigned", field as "priority", value); + setPage(0); + }} + /> + + + +
+ + {filtered().length} issue{filtered().length !== 1 ? "s" : ""} + + { + setTabFilter("jiraAssigned", "sortField", field); + setTabFilter("jiraAssigned", "sortDirection", dir); + setPage(0); + }} + /> + setAllExpanded(TAB_KEY, projectKeys(), true)} + onCollapseAll={() => setAllExpanded(TAB_KEY, projectKeys(), false)} + /> +
+
+ + +
+ +
+
+ + +
+

+ {(filters().statusCategory !== "all" || filters().priority !== "all") + ? "No issues match current filters" + : `No ${filters().scope === "reported" ? "created" : filters().scope === "watching" ? "watched" : "assigned"} Jira issues`} +

+
+
+ + 0}> +
+ + {(group) => { + const isEmpty = () => group.items.length === 0; + const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[TAB_KEY] ?? {})[group.repoFullName]; + + return ( +
+
+ + +
+ +
+ + {(issue) => { + const isPinned = () => pinnedJiraKeys().has(issue.key); + const browseUrl = () => isSafeJiraSiteUrl(props.siteUrl) ? `${props.siteUrl}/browse/${issue.key}` : "#"; + return ( +
+
+
+ + {(type) => { + const [imgFailed, setImgFailed] = createSignal(false); + return ( + + } + > + {type().name} setImgFailed(true)} + /> + + + ); + }} + + + {issue.key} + + + + {issue.fields.summary} + + +
+ +

+ {issue.fields.summary} +

+
+
+
+ + + {stripParenthetical(issue.fields.priority!.name)} + + + + {issue.fields.status.name} + +
+ + + +
+ ); + }} +
+
+
+ +
+ No matching issues in {group.repoFullName} +
+
+
+ ); + }} +
+
+ 1}> +
+ setPage((p) => Math.max(0, p - 1))} + onNext={() => setPage((p) => Math.min(pageCount() - 1, p + 1))} + /> +
+
+
+
+ ); +} diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index d5faa7fa..9535246a 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -35,6 +35,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { let assignedIssues = 0; for (const i of props.issues) { if (ignored.has(i.id)) continue; + if (i.state !== "OPEN") continue; if (viewState.hideDepDashboard && i.title === "Dependency Dashboard") continue; if (i.assigneeLogins.some((a) => a.toLowerCase() === login)) assignedIssues++; } @@ -51,6 +52,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { let prsBlocked = 0; for (const pr of props.pullRequests) { if (ignored.has(pr.id)) continue; + if (pr.state !== "OPEN") continue; const isAuthor = pr.userLogin.toLowerCase() === login; if ( !isAuthor && diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 9bda6dc1..aa8d4a6b 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -26,6 +26,8 @@ import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; import EmptyLockedRepoRow from "../shared/EmptyLockedRepoRow"; import { Tooltip } from "../shared/Tooltip"; +import JiraBadge from "../shared/JiraBadge"; +import { extractJiraKeys } from "../../../shared/validation"; export interface PullRequestsTabProps { pullRequests: PullRequest[]; @@ -39,6 +41,7 @@ export interface PullRequestsTabProps { refreshTick?: number; customTabId?: string; filterPreset?: Record; + jiraKeyMap?: () => ReadonlyMap; } type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size"; @@ -154,6 +157,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const meta = new Map; sizeCategory: ReturnType }>(); let items = props.pullRequests.filter((pr) => { + if (pr.state !== "OPEN") return false; if (!isPrVisible(pr, { ignoredIds, globalFilter })) return false; const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins, upstreamRepoSet().has(pr.repoFullName)); @@ -325,7 +329,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (trackedPrIds().has(pr.id)) { untrackItem(pr.id, "pullRequest"); } else { - trackItem({ id: pr.id, number: pr.number, type: "pullRequest", repoFullName: pr.repoFullName, title: pr.title, addedAt: Date.now() }); + trackItem({ id: pr.id, number: pr.number, type: "pullRequest", source: "github", repoFullName: pr.repoFullName, title: pr.title, addedAt: Date.now() }); } } @@ -620,6 +624,29 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { + + {(() => { + const titleKeys = new Set(extractJiraKeys(pr.title)); + const branchKeys = new Set(extractJiraKeys(pr.headRef ?? "")); + const annotated = [...new Set([...titleKeys, ...branchKeys])].map((key) => ({ + key, + source: (titleKeys.has(key) && branchKeys.has(key) ? "title & branch" + : titleKeys.has(key) ? "title" : "branch") as "title" | "branch" | "title & branch", + })); + return ( + + {(entry) => ( + + )} + + ); + })()} + )} diff --git a/src/app/components/dashboard/TrackedTab.tsx b/src/app/components/dashboard/TrackedTab.tsx index cc1c22b9..f48191c0 100644 --- a/src/app/components/dashboard/TrackedTab.tsx +++ b/src/app/components/dashboard/TrackedTab.tsx @@ -1,6 +1,6 @@ import { For, Show, Switch, Match, createMemo } from "solid-js"; import { config } from "../../stores/config"; -import { viewState, untrackItem, moveTrackedItem } from "../../stores/view"; +import { viewState, untrackItem, moveTrackedItem, untrackJiraItem, moveJiraItem } from "../../stores/view"; import type { TrackedItem } from "../../stores/view"; import type { Issue, PullRequest } from "../../services/api"; import { deriveInvolvementRoles, prSizeCategory } from "../../lib/format"; @@ -21,6 +21,9 @@ function TypeBadge(props: { type: TrackedItem["type"] }) { PR + + Jira + ); } @@ -55,12 +58,18 @@ function animateMove(before: Map) { }); } -function handleMove(id: number, type: "issue" | "pullRequest", direction: "up" | "down") { +function handleGitHubMove(id: number, type: "issue" | "pullRequest", direction: "up" | "down") { const before = recordPositions(); moveTrackedItem(id, type, direction); animateMove(before); } +function handleJiraMove(jiraKey: string, direction: "up" | "down") { + const before = recordPositions(); + moveJiraItem(jiraKey, direction); + animateMove(before); +} + export interface TrackedTabProps { issues: Issue[]; pullRequests: PullRequest[]; @@ -77,6 +86,7 @@ export default function TrackedTab(props: TrackedTabProps) { } const prMap = new Map(); for (const pr of props.pullRequests) { + if (pr.state !== "OPEN") continue; prMap.set(pr.id, pr); } return { issueMap, prMap }; @@ -95,11 +105,6 @@ export default function TrackedTab(props: TrackedTabProps) {
{(item, index) => { - const liveData = () => - item.type === "issue" - ? maps().issueMap.get(item.id) - : maps().prMap.get(item.id); - const isFirst = () => index() === 0; const isLast = () => index() === viewState.trackedItems.length - 1; const itemKey = `${item.type}:${item.id}`; @@ -112,151 +117,207 @@ export default function TrackedTab(props: TrackedTabProps) { {/* Reorder buttons */}
- {/* Row content */} + {/* Row content — source-based split */}
+ + {item.title} +
- {item.repoFullName}{" "} - (not in current data) + {item.jiraProjectKey ?? item.repoFullName}
- +
} > - {(live) => { - const pr = createMemo(() => item.type === "pullRequest" ? live() as PullRequest : undefined); - const commentCount = createMemo(() => - item.type === "pullRequest" - ? (pr()!.enriched !== false ? pr()!.comments + pr()!.reviewThreads : undefined) - : (live() as Issue).comments); + {/* GitHub item rendering (source === "github" or legacy undefined) */} + {(() => { + const liveData = () => + item.type === "issue" + ? maps().issueMap.get(item.id) + : maps().prMap.get(item.id); return ( - untrackItem(item.id, item.type)} - isTracked={true} - isPolling={item.type === "pullRequest" && props.hotPollingPRIds?.has(item.id)} - > - - - + +
+
+ + {item.title} + + +
+
+ {item.repoFullName}{" "} + (not in current data) +
- } - > - {(prData) => { - const roles = createMemo(() => deriveInvolvementRoles(props.userLogin, prData().userLogin, prData().assigneeLogins, prData().reviewerLogins)); - const sizeCategory = createMemo(() => prSizeCategory(prData().additions, prData().deletions)); + + + +
+ } + > + {(live) => { + const pr = createMemo(() => item.type === "pullRequest" ? live() as PullRequest : undefined); + const commentCount = createMemo(() => + item.type === "pullRequest" + ? (pr()!.enriched !== false ? pr()!.comments + pr()!.reviewThreads : undefined) + : (live() as Issue).comments); - return ( + return ( + untrackItem(item.id, item.type as "issue" | "pullRequest")} + isTracked={true} + isPolling={item.type === "pullRequest" && props.hotPollingPRIds?.has(item.id)} + > - - - - - - - - - - - - - - - - - - Draft - +
+ +
} > - {/* Compact: key badges inline */} -
- - - - - - - - - - - - - - - D - -
+ {(prData) => { + const roles = createMemo(() => deriveInvolvementRoles(props.userLogin, prData().userLogin, prData().assigneeLogins, prData().reviewerLogins)); + const sizeCategory = createMemo(() => prSizeCategory(prData().additions, prData().deletions)); + + return ( + + + + + + + + + + + + + + + + + + + Draft + + + } + > +
+ + + + + + + + + + + + + + + D + +
+
+ ); + }}
- ); - }} - -
+ + ); + }} + ); - }} + })()} diff --git a/src/app/components/layout/TabBar.tsx b/src/app/components/layout/TabBar.tsx index 59bcf2be..7c21652a 100644 --- a/src/app/components/layout/TabBar.tsx +++ b/src/app/components/layout/TabBar.tsx @@ -11,6 +11,7 @@ interface TabBarProps { onTabChange: (tab: TabId) => void; counts?: TabCounts; enableTracking?: boolean; + enableJira?: boolean; customTabs?: Array<{ id: string; name: string }>; onAddTab?: () => void; onEditTab?: (id: string) => void; @@ -22,7 +23,7 @@ export default function TabBar(props: TabBarProps) {
- + Issues @@ -49,6 +50,14 @@ export default function TabBar(props: TabBarProps) { + + + Jira Assigned + + {props.counts?.jiraAssigned} + + + {/* Wrapper
around custom tab triggers is safe for Kobalte keyboard nav: Kobalte uses querySelector('[data-key="..."]') for focus management and a Collection-based delegate for Arrow Left/Right — neither depend on direct children. */} diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 7ff274ed..32a3e8fb 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -1,13 +1,15 @@ import { createSignal, createMemo, Show, For, onCleanup, onMount } from "solid-js"; +import * as Sentry from "@sentry/solid"; import { getRelayStatus } from "../../lib/mcp-relay"; import { useNavigate } from "@solidjs/router"; -import { config, updateConfig, setMonitoredRepo } from "../../stores/config"; +import { config, updateConfig, updateJiraConfig, setMonitoredRepo } from "../../stores/config"; import type { Config } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; -import { clearAuth } from "../../stores/auth"; +import { clearAuth, jiraAuth, setJiraAuth, clearJiraAuth, isJiraAuthenticated } from "../../stores/auth"; import { clearCache } from "../../stores/cache"; import { pushNotification } from "../../lib/errors"; -import { buildOrgAccessUrl } from "../../lib/oauth"; +import { buildOrgAccessUrl, buildJiraAuthorizeUrl } from "../../lib/oauth"; +import { sealApiToken } from "../../lib/proxy"; import { isSafeGitHubUrl, openGitHubUrl } from "../../lib/url"; import { relativeTime } from "../../lib/format"; import { fetchOrgs } from "../../services/api"; @@ -24,6 +26,8 @@ import CustomTabsSection from "./CustomTabsSection"; import { InfoTooltip } from "../shared/Tooltip"; import type { RepoRef } from "../../services/api"; +const VALID_JIRA_CLIENT_ID_RE = /^[A-Za-z0-9_-]+$/; + export default function SettingsPage() { const navigate = useNavigate(); @@ -171,6 +175,15 @@ export default function SettingsPage() { rememberLastTab: config.rememberLastTab, enableTracking: config.enableTracking, customTabs: config.customTabs, + // Non-secret jira config fields only — no tokens, sealed blobs, or email + jira: { + enabled: config.jira?.enabled ?? false, + authMethod: config.jira?.authMethod ?? "oauth", + issueKeyDetection: config.jira?.issueKeyDetection ?? true, + cloudId: config.jira?.cloudId, + siteName: config.jira?.siteName, + siteUrl: config.jira?.siteUrl, + }, }, null, 2 @@ -200,6 +213,114 @@ export default function SettingsPage() { navigate("/login"); } + // ── Jira integration ────────────────────────────────────────────────────── + + const jiraClientId = import.meta.env.VITE_JIRA_CLIENT_ID as string | undefined; + const jiraEnabled = !!jiraClientId && VALID_JIRA_CLIENT_ID_RE.test(jiraClientId); + + const [jiraApiEmail, setJiraApiEmail] = createSignal(""); + const [jiraApiToken, setJiraApiToken] = createSignal(""); + const [jiraApiSubdomain, setJiraApiSubdomain] = createSignal(""); + const [jiraApiConnecting, setJiraApiConnecting] = createSignal(false); + const [jiraApiError, setJiraApiError] = createSignal(null); + const [jiraApiMode, setJiraApiMode] = createSignal(false); + + const jiraApiSiteUrl = () => { + const sub = jiraApiSubdomain().trim(); + return sub ? `https://${sub}.atlassian.net` : ""; + }; + + function handleJiraOAuthConnect() { + try { + const url = buildJiraAuthorizeUrl(); + window.location.href = url; + } catch { + pushNotification("jira:connect", "Jira client ID is not configured — check VITE_JIRA_CLIENT_ID", "warning"); + } + } + + async function handleJiraApiTokenConnect() { + const email = jiraApiEmail().trim(); + const token = jiraApiToken().trim(); + const siteUrl = jiraApiSiteUrl(); + if (!email || !token || !siteUrl) { + setJiraApiError("Email, API token, and site name are all required."); + return; + } + setJiraApiConnecting(true); + setJiraApiError(null); + try { + // Auto-discover Cloud ID from site URL + const tenantResp = await fetch("/api/jira/tenant-info", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Requested-With": "fetch" }, + body: JSON.stringify({ siteUrl }), + }); + if (!tenantResp.ok) { + setJiraApiError("Could not look up your Jira site — check the site URL and try again."); + return; + } + const tenantData = await tenantResp.json() as { cloudId: string }; + const cloudId = tenantData.cloudId; + if (!cloudId) { + setJiraApiError("Could not determine Cloud ID from your Jira site URL."); + return; + } + + const sealedToken = await sealApiToken(token, "jira-api-token"); + // Validate by making a search request through the proxy + const resp = await fetch("/api/jira/proxy", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Requested-With": "fetch" }, + body: JSON.stringify({ + endpoint: "search", + cloudId, + email, + sealed: sealedToken, + params: { jql: "assignee = currentUser() AND statusCategory != Done", maxResults: 1 }, + }), + }); + if (!resp.ok) { + setJiraApiError("Could not connect — check your email and API token."); + return; + } + let siteName: string; + try { siteName = new URL(siteUrl).hostname.split(".")[0]; } catch { siteName = cloudId; } + setJiraAuth({ + accessToken: sealedToken, + sealedRefreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + cloudId, + siteUrl, + siteName, + email, + }); + updateJiraConfig({ enabled: true, cloudId, email, authMethod: "token", siteUrl, siteName }); + setJiraApiEmail(""); + setJiraApiToken(""); + setJiraApiSubdomain(""); + setJiraApiMode(false); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + console.error("[jira-connect]", err); + Sentry.captureException(err, { tags: { source: "jira-api-token-connect" } }); + setJiraApiError(`Connection failed: ${msg}`); + } finally { + setJiraApiConnecting(false); + } + } + + function handleJiraDisconnect() { + clearJiraAuth(); + // DefaultTab guard: reset to issues if pointing at Jira tab + if (config.defaultTab === "jiraAssigned") { + updateConfig({ defaultTab: "issues" }); + } + if (viewState.lastActiveTab === "jiraAssigned") { + updateViewState({ lastActiveTab: "issues" }); + } + } + // ── Refresh interval options ────────────────────────────────────────────── const refreshOptions = [ @@ -217,6 +338,7 @@ export default function SettingsPage() { { value: "pullRequests", label: "Pull Requests" }, { value: "actions", label: "GitHub Actions" }, ...(config.enableTracking ? [{ value: "tracked", label: "Tracked Items" }] : []), + ...(config.jira?.enabled ? [{ value: "jiraAssigned", label: "Jira" }] : []), ...config.customTabs.map((t) => ({ value: t.id, label: t.name })), ]); @@ -754,7 +876,149 @@ export default function SettingsPage() { - {/* Section 11: Data */} + {/* Section 11: Jira Cloud Integration */} +
+ + +

+ Enter your Atlassian email, an{" "} + + API token + + , and your Jira Cloud ID. Use Create API token (not "with + scopes") — it inherits your account's access to Jira projects. The token is + used read-only and encrypted before storage. +

+ setJiraApiEmail(e.currentTarget.value)} + class="input input-sm w-full" + aria-label="Atlassian account email" + /> + setJiraApiToken(e.currentTarget.value)} + class="input input-sm w-full" + aria-label="Atlassian API token" + /> +
+ https:// + setJiraApiSubdomain(e.currentTarget.value)} + class="input input-sm w-32" + aria-label="Jira site name" + /> + .atlassian.net +
+ +

{jiraApiError()}

+
+
+ + +
+
+ } + > +

+ Connect your Jira Cloud account to see assigned issues and detect Jira keys in GitHub items. +

+
+ + + + +
+ +
+ } + > + + {jiraAuth()?.siteName ?? ""} + + + {config.jira?.authMethod === "token" ? "API Token" : "OAuth"} + + + updateJiraConfig({ issueKeyDetection: e.currentTarget.checked })} + class="toggle toggle-primary" + /> + + + + + + + + {/* Data */}
{/* Authentication method */} { + const parts: string[] = []; + if (props.issue) parts.push(props.issue.fields.status.name); + if (props.issue?.fields.summary) parts.push(props.issue.fields.summary); + if (props.source && props.source !== "title & branch") { + parts.push(`(discovered from PR ${props.source})`); + } + return parts.join("\n") || props.issueKey; + }; + + return ( + + + + {props.issueKey} + + } + > + {(issue) => ( + + {props.issueKey} + + )} + + + + ); +} diff --git a/src/app/components/shared/SortDropdown.tsx b/src/app/components/shared/SortDropdown.tsx index 8685dc4b..cbf27f1a 100644 --- a/src/app/components/shared/SortDropdown.tsx +++ b/src/app/components/shared/SortDropdown.tsx @@ -4,7 +4,8 @@ import { Select } from "@kobalte/core/select"; export interface SortOption { label: string; field: string; - type: "date" | "text" | "number"; + type: "date" | "text" | "number" | "priority" | "status"; + preferredDirection?: "asc" | "desc"; } interface SortDropdownProps { @@ -22,15 +23,21 @@ interface FlatOption { function suffixFor(type: SortOption["type"], dir: "asc" | "desc"): string { if (type === "date") return dir === "desc" ? "(newest first)" : "(oldest first)"; if (type === "text") return dir === "asc" ? "(A-Z)" : "(Z-A)"; + if (type === "priority") return dir === "asc" ? "(highest first)" : "(lowest first)"; + if (type === "status") return dir === "asc" ? "(To Do → Done)" : "(Done → To Do)"; return dir === "desc" ? "(most)" : "(fewest)"; } export default function SortDropdown(props: SortDropdownProps) { const flatOptions = createMemo(() => - props.options.flatMap((opt) => [ - { value: `${opt.field}:desc`, label: `${opt.label} ${suffixFor(opt.type, "desc")}` }, - { value: `${opt.field}:asc`, label: `${opt.label} ${suffixFor(opt.type, "asc")}` }, - ]) + props.options.flatMap((opt) => { + const first = opt.preferredDirection ?? "desc"; + const second = first === "desc" ? "asc" : "desc"; + return [ + { value: `${opt.field}:${first}`, label: `${opt.label} ${suffixFor(opt.type, first)}` }, + { value: `${opt.field}:${second}`, label: `${opt.label} ${suffixFor(opt.type, second)}` }, + ]; + }) ); const selected = () => `${props.value}:${props.direction}`; diff --git a/src/app/lib/format.ts b/src/app/lib/format.ts index 6caffa6c..fe48e008 100644 --- a/src/app/lib/format.ts +++ b/src/app/lib/format.ts @@ -1,6 +1,19 @@ // Re-exports from shared/format for backward compat with existing importers. export { relativeTime, shortRelativeTime, labelTextColor, formatDuration, prSizeCategory, deriveInvolvementRoles, formatCount, formatStarCount } from "../../shared/format"; +export function stripParenthetical(name: string): string { + return name.replace(/\s*\(.*\)$/, ""); +} + +export function jiraStatusCategoryClass(key: string): string { + switch (key) { + case "new": return "badge-info"; + case "indeterminate": return "badge-warning"; + case "done": return "badge-success"; + default: return "badge-ghost"; + } +} + export function rateLimitCssClass(remaining: number, limit: number): string { if (remaining === 0) return "text-error"; if (remaining < limit * 0.1) return "text-warning"; diff --git a/src/app/lib/mcp-relay.ts b/src/app/lib/mcp-relay.ts index c57e1ec0..4309fc58 100644 --- a/src/app/lib/mcp-relay.ts +++ b/src/app/lib/mcp-relay.ts @@ -119,10 +119,10 @@ function handleRequest(ws: WebSocket, req: JsonRpcRequest): void { // Relay snapshot is inherently scoped to the user's items (SPA uses `involves:{user}`). // The `scope` param is intentionally ignored — relay always reflects the user's dashboard. const s = snapshot!; - const openPRs = s.pullRequests.filter((p) => p.state === "open"); + const openPRs = s.pullRequests.filter((p) => p.state === "OPEN"); const result = { openPRCount: openPRs.length, - openIssueCount: s.issues.filter((i) => i.state === "open").length, + openIssueCount: s.issues.filter((i) => i.state === "OPEN").length, failingRunCount: s.workflowRuns.filter( (r) => r.conclusion === "failure" || r.conclusion === "timed_out" ).length, @@ -135,7 +135,7 @@ function handleRequest(ws: WebSocket, req: JsonRpcRequest): void { case METHODS.GET_OPEN_PRS: { const params = req.params ?? {}; - let prs = snapshot!.pullRequests.filter((p) => p.state === "open"); + let prs = snapshot!.pullRequests.filter((p) => p.state === "OPEN"); if (typeof params["repo"] === "string" && params["repo"]) { prs = prs.filter((p) => p.repoFullName === params["repo"]); } @@ -163,7 +163,7 @@ function handleRequest(ws: WebSocket, req: JsonRpcRequest): void { case METHODS.GET_OPEN_ISSUES: { const params = req.params ?? {}; - let issues = snapshot!.issues.filter((i) => i.state === "open"); + let issues = snapshot!.issues.filter((i) => i.state === "OPEN"); if (typeof params["repo"] === "string" && params["repo"]) { issues = issues.filter((i) => i.repoFullName === params["repo"]); } diff --git a/src/app/lib/oauth.ts b/src/app/lib/oauth.ts index 217e8093..af45fe12 100644 --- a/src/app/lib/oauth.ts +++ b/src/app/lib/oauth.ts @@ -1,5 +1,6 @@ export const OAUTH_STATE_KEY = "github-tracker:oauth-state"; export const OAUTH_RETURN_TO_KEY = "github-tracker:oauth-return-to"; +export const JIRA_OAUTH_STATE_KEY = "github-tracker:jira-oauth-state"; export function generateOAuthState(): string { const stateBytes = crypto.getRandomValues(new Uint8Array(16)); @@ -29,8 +30,8 @@ export function buildAuthorizeUrl(options?: { returnTo?: string }): string { const params = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, - // repo: read issues/PRs; read:org: list orgs; notifications: gate - scope: "repo read:org notifications", + // repo: read issues/PRs; read:org: list orgs + scope: "repo read:org", state, prompt: "select_account", }); @@ -39,6 +40,25 @@ export function buildAuthorizeUrl(options?: { returnTo?: string }): string { const VALID_CLIENT_ID_RE = /^[A-Za-z0-9_-]+$/; +export function buildJiraAuthorizeUrl(): string { + const clientId = import.meta.env.VITE_JIRA_CLIENT_ID as string | undefined; + if (!clientId || !VALID_CLIENT_ID_RE.test(clientId)) { + throw new Error("Invalid or missing VITE_JIRA_CLIENT_ID"); + } + const state = generateOAuthState(); + sessionStorage.setItem(JIRA_OAUTH_STATE_KEY, state); + const params = new URLSearchParams({ + audience: "api.atlassian.com", + client_id: clientId, + scope: "read:jira-work read:jira-user offline_access", + redirect_uri: `${window.location.origin}/jira/callback`, + state, + response_type: "code", + prompt: "consent", + }); + return `https://auth.atlassian.com/authorize?${params.toString()}`; +} + /** * Links to the per-app authorization page where users can see org access * status and request access for orgs with OAuth restrictions enabled. diff --git a/src/app/lib/proxy.ts b/src/app/lib/proxy.ts index b31df324..d49e0d55 100644 --- a/src/app/lib/proxy.ts +++ b/src/app/lib/proxy.ts @@ -13,7 +13,6 @@ function loadTurnstileScript(): Promise { turnstilePromise = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = TURNSTILE_SCRIPT_URL; - script.async = true; script.onload = () => resolve(); script.onerror = () => { script.remove(); @@ -60,50 +59,46 @@ export async function acquireTurnstileToken(siteKey: string): Promise { reject(new Error("Turnstile challenge timed out after 30 seconds")); }, 30_000); - window.turnstile.ready(() => { + try { + const widgetId = window.turnstile.render(container, { + sitekey: siteKey, + action: "seal", + size: "compact", + execution: "execute", + retry: "never", + callback: (token: string) => { + if (settled) return; + settled = true; + cleanup(); + resolve(token); + }, + "error-callback": (errorCode: string) => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(`Turnstile error: ${errorCode}`)); + }, + "expired-callback": () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Turnstile token expired before submission")); + }, + "timeout-callback": () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Turnstile challenge timed out")); + }, + }); + currentWidgetId = widgetId; + window.turnstile.execute(widgetId); + } catch (err) { if (settled) return; - - try { - const widgetId = window.turnstile.render(container, { - sitekey: siteKey, - action: "seal", - size: "invisible", - execution: "execute", - retry: "never", - callback: (token: string) => { - if (settled) return; - settled = true; - cleanup(); - resolve(token); - }, - "error-callback": (errorCode: string) => { - if (settled) return; - settled = true; - cleanup(); - reject(new Error(`Turnstile error: ${errorCode}`)); - }, - "expired-callback": () => { - if (settled) return; - settled = true; - cleanup(); - reject(new Error("Turnstile token expired before submission")); - }, - "timeout-callback": () => { - if (settled) return; - settled = true; - cleanup(); - reject(new Error("Turnstile challenge timed out")); - }, - }); - currentWidgetId = widgetId; - window.turnstile.execute(widgetId); - } catch (err) { - if (settled) return; - settled = true; - cleanup(); - reject(err instanceof Error ? err : new Error("Turnstile render failed")); - } - }); + settled = true; + cleanup(); + reject(err instanceof Error ? err : new Error("Turnstile render failed")); + } }); } diff --git a/src/app/lib/url.ts b/src/app/lib/url.ts index e49342de..016211eb 100644 --- a/src/app/lib/url.ts +++ b/src/app/lib/url.ts @@ -12,6 +12,17 @@ export function isSafeGitHubUrl(url: string): boolean { } } +const ATLASSIAN_HOST_RE = /^[a-z0-9-]+\.atlassian\.net$/i; + +export function isSafeJiraSiteUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && ATLASSIAN_HOST_RE.test(parsed.hostname); + } catch { + return false; + } +} + /** * Opens a GitHub URL in a new tab after validation. * No-ops for non-GitHub URLs. diff --git a/src/app/pages/JiraCallback.tsx b/src/app/pages/JiraCallback.tsx new file mode 100644 index 00000000..6aa6d7b8 --- /dev/null +++ b/src/app/pages/JiraCallback.tsx @@ -0,0 +1,205 @@ +import { createSignal, onMount, Show, For } from "solid-js"; +import * as Sentry from "@sentry/solid"; +import { useNavigate } from "@solidjs/router"; +import { z } from "zod"; +import { setJiraAuth } from "../stores/auth"; +import { updateJiraConfig } from "../stores/config"; +import { JIRA_OAUTH_STATE_KEY } from "../lib/oauth"; +import { acquireTurnstileToken } from "../lib/proxy"; +import { JiraClient } from "../services/jira-client"; +import type { JiraAccessibleResource } from "../../shared/jira-types"; +import LoadingSpinner from "../components/shared/LoadingSpinner"; + +const JiraAccessibleResourceSchema = z.array(z.object({ + id: z.string(), + name: z.string(), + url: z.string(), + scopes: z.array(z.string()), + avatarUrl: z.string().optional(), +})); + +const JiraTokenResponseSchema = z.object({ + access_token: z.string(), + sealed_refresh_token: z.string(), + expires_in: z.number(), +}); +type JiraTokenResponse = z.infer; + +function JiraSitePicker(props: { + sites: JiraAccessibleResource[]; + onSelect: (site: JiraAccessibleResource) => void; +}) { + return ( +
+

+ Select the Jira Cloud site to connect: +

+
    + + {(site) => ( +
  • + +
  • + )} +
    +
+
+ ); +} + +export default function JiraCallback() { + const navigate = useNavigate(); + const [error, setError] = createSignal(null); + const [sites, setSites] = createSignal(null); + const [pendingToken, setPendingToken] = createSignal(null); + + async function completeSiteSelection(site: JiraAccessibleResource, tokenData: JiraTokenResponse) { + // email intentionally omitted: OAuth uses Bearer token, not Basic (email + API token). + setJiraAuth({ + accessToken: tokenData.access_token, + sealedRefreshToken: tokenData.sealed_refresh_token, + expiresAt: Date.now() + (typeof tokenData.expires_in === "number" && tokenData.expires_in > 0 ? Math.max(tokenData.expires_in, 60) : 3600) * 1000, + cloudId: site.id, + siteUrl: site.url, + siteName: site.name, + }); + updateJiraConfig({ enabled: true, cloudId: site.id, siteUrl: site.url, siteName: site.name, authMethod: "oauth" }); + navigate("/settings", { replace: true }); + } + + onMount(async () => { + const params = new URLSearchParams(window.location.search); + const code = params.get("code"); + const stateFromUrl = params.get("state"); + + // Retrieve and immediately clear stored state (single-use CSRF token) + const storedState = sessionStorage.getItem(JIRA_OAUTH_STATE_KEY); + sessionStorage.removeItem(JIRA_OAUTH_STATE_KEY); + + if (!stateFromUrl || !storedState || stateFromUrl !== storedState) { + setError("Invalid OAuth state. Please try connecting Jira again."); + console.info("[jira] OAuth state mismatch — possible CSRF attempt"); + return; + } + + if (!code) { + setError("No authorization code received from Atlassian."); + return; + } + + // Acquire Turnstile token before exchange + let turnstileToken: string; + try { + turnstileToken = await acquireTurnstileToken(import.meta.env.VITE_TURNSTILE_SITE_KEY as string ?? ""); + } catch (err) { + Sentry.captureException(err, { tags: { source: "jira-callback-turnstile" } }); + setError("Human verification failed. Please try again."); + return; + } + + // Exchange code for tokens via Worker + let tokenData: JiraTokenResponse; + try { + const resp = await fetch("/api/oauth/jira/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "fetch", + "cf-turnstile-response": turnstileToken, + }, + body: JSON.stringify({ code }), + }); + + if (!resp.ok) { + setError("Failed to complete Jira sign in. Please try again."); + console.info("[jira] token exchange failed", resp.status); + return; + } + + const rawToken = await resp.json(); + const tokenParsed = JiraTokenResponseSchema.safeParse(rawToken); + if (!tokenParsed.success) { + setError("Failed to complete Jira sign in. Please try again."); + return; + } + tokenData = tokenParsed.data; + } catch (err) { + Sentry.captureException(err, { tags: { source: "jira-callback-token-exchange" } }); + setError("A network error occurred. Please try again."); + return; + } + + // Site discovery via direct browser call (Atlassian supports CORS for OAuth) + let resources: JiraAccessibleResource[]; + try { + const rawResources = await JiraClient.getAccessibleResources(tokenData.access_token); + const parsed = JiraAccessibleResourceSchema.safeParse(rawResources); + if (!parsed.success) { + setError("Unexpected response from Atlassian. Please try again."); + return; + } + resources = parsed.data; + } catch (err) { + Sentry.captureException(err, { tags: { source: "jira-callback-site-discovery" } }); + setError("Failed to discover Jira sites. Please try again."); + return; + } + + if (resources.length === 0) { + setError("No Jira Cloud sites found. Ensure your Atlassian account has access to at least one Jira site."); + return; + } + + if (resources.length === 1) { + await completeSiteSelection(resources[0], tokenData); + return; + } + + // Multiple sites — show picker + setPendingToken(tokenData); + setSites(resources); + }); + + return ( +
+
+ +
+

Connection Error

+

{error()}

+ + Return to Settings + +
+
+ + {(resolvedSites) => ( +
+

Connect Jira Site

+ { + const token = pendingToken(); + if (token) void completeSiteSelection(site, token); + }} + /> +
+ )} +
+ +
+

Connecting to Jira

+ +
+
+
+
+ ); +} diff --git a/src/app/pages/LoginPage.tsx b/src/app/pages/LoginPage.tsx index 824c5ecb..13558739 100644 --- a/src/app/pages/LoginPage.tsx +++ b/src/app/pages/LoginPage.tsx @@ -1,4 +1,5 @@ import { createSignal, onMount, Show } from "solid-js"; +import * as Sentry from "@sentry/solid"; import { useNavigate } from "@solidjs/router"; import { setAuthFromPat, type GitHubUser } from "../stores/auth"; import { @@ -65,7 +66,8 @@ export default function LoginPage() { setAuthFromPat(trimmedToken, userData); setPatInput(""); navigate("/", { replace: true }); - } catch { + } catch (err) { + Sentry.captureException(err, { tags: { source: "pat-validation" } }); setPatError("Network error — please try again"); } finally { setSubmitting(false); @@ -129,7 +131,6 @@ export default function LoginPage() {
  • repo
  • read:org (under admin:org)
  • -
  • notifications
@@ -142,7 +143,7 @@ export default function LoginPage() { > Fine-grained tokens - {" "}also work, but only access one org at a time and do not support notifications. Add read-only permissions for Actions, Contents, Issues, and Pull requests. + {" "}also work, but only access one org at a time. Add read-only permissions for Actions, Contents, Issues, and Pull requests.

diff --git a/src/app/pages/OAuthCallback.tsx b/src/app/pages/OAuthCallback.tsx index ed605164..e9a4413d 100644 --- a/src/app/pages/OAuthCallback.tsx +++ b/src/app/pages/OAuthCallback.tsx @@ -1,4 +1,5 @@ import { createSignal, onMount, Show } from "solid-js"; +import * as Sentry from "@sentry/solid"; import { useNavigate } from "@solidjs/router"; import { setAuth, validateToken, clearAuth } from "../stores/auth"; import { OAUTH_STATE_KEY, OAUTH_RETURN_TO_KEY, sanitizeReturnTo } from "../lib/oauth"; @@ -64,7 +65,8 @@ export default function OAuthCallback() { const returnTo = sessionStorage.getItem(OAUTH_RETURN_TO_KEY); sessionStorage.removeItem(OAUTH_RETURN_TO_KEY); navigate(sanitizeReturnTo(returnTo), { replace: true }); - } catch { + } catch (err) { + Sentry.captureException(err, { tags: { source: "oauth-callback" } }); setError("A network error occurred. Please try again."); } }); diff --git a/src/app/services/api-usage.ts b/src/app/services/api-usage.ts index 4c604779..33c82e24 100644 --- a/src/app/services/api-usage.ts +++ b/src/app/services/api-usage.ts @@ -8,7 +8,7 @@ import { onApiRequest, type ApiRequestInfo } from "./github"; const API_CALL_SOURCES = [ "lightSearch", "heavyBackfill", "forkCheck", "globalUserSearch", "unfilteredSearch", - "upstreamDiscovery", "workflowRuns", "hotPRStatus", "hotRunStatus", "notifications", + "upstreamDiscovery", "workflowRuns", "hotPRStatus", "hotRunStatus", "userEvents", "validateUser", "fetchOrgs", "fetchRepos", "rateLimitCheck", "graphql", "rest", ] as const; @@ -28,7 +28,7 @@ export const SOURCE_LABELS: Record = { workflowRuns: "Workflow Runs", hotPRStatus: "Hot PR Status", hotRunStatus: "Hot Run Status", - notifications: "Notifications", + userEvents: "Events", validateUser: "Validate User", fetchOrgs: "Fetch Orgs", fetchRepos: "Fetch Repos", @@ -195,7 +195,7 @@ export function updateResetAt(resetAt: number): void { // /^\/user$/ uses $ to avoid shadowing /user/orgs and /user/repos. // /actions/runs/\d+$ must precede /actions/runs/ (specific before general). const REST_SOURCE_PATTERNS: Array<[RegExp, ApiCallSource]> = [ - [/^\/notifications/, "notifications"], + [/^\/users\/[^/]+\/events/, "userEvents"], [/^\/users\/[^/]+$/, "validateUser"], [/^\/user$/, "fetchOrgs"], [/^\/user\/orgs/, "fetchOrgs"], diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 17eddc7c..cf13b193 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -4,10 +4,10 @@ import { pushNotification } from "../lib/errors"; import type { ApiCallSource } from "./api-usage"; import type { TrackedUser } from "../stores/config"; import { VALID_REPO_NAME, VALID_TRACKED_LOGIN, SEARCH_RESULT_CAP } from "../../shared/validation"; -import type { Issue, PullRequest, WorkflowRun, RepoRef, RepoEntry, OrgEntry, CheckStatus, ApiError } from "../../shared/types"; +import type { Issue, IssueState, PullRequest, PullRequestState, WorkflowRun, RepoRef, RepoEntry, OrgEntry, CheckStatus, ApiError } from "../../shared/types"; // ── Re-exports from shared/types (backward compat for existing importers) ───── -export type { Issue, PullRequest, WorkflowRun, RepoRef, RepoEntry, OrgEntry, CheckStatus, ApiError, RateLimitInfo, DashboardSummary } from "../../shared/types"; +export type { Issue, IssueState, PullRequest, PullRequestState, WorkflowRun, RepoRef, RepoEntry, OrgEntry, CheckStatus, ApiError, RateLimitInfo, DashboardSummary } from "../../shared/types"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -161,7 +161,7 @@ interface GraphQLIssueNode { databaseId: number; number: number; title: string; - state: string; + state: IssueState; url: string; createdAt: string; updatedAt: string; @@ -424,7 +424,7 @@ const HOT_PR_STATUS_QUERY = ` interface HotPRStatusNode { databaseId: number; - state: string; + state: PullRequestState; mergeStateStatus: string; reviewDecision: string | null; commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] }; @@ -440,7 +440,7 @@ interface GraphQLLightPRNode { databaseId: number; number: number; title: string; - state: string; + state: PullRequestState; isDraft: boolean; url: string; createdAt: string; @@ -1649,7 +1649,7 @@ export async function fetchWorkflowRuns( // ── Hot poll: targeted status updates ──────────────────────────────────────── export interface HotPRStatusUpdate { - state: string; + state: PullRequestState; checkStatus: CheckStatus["status"]; mergeStateStatus: string; reviewDecision: PullRequest["reviewDecision"]; diff --git a/src/app/services/events.ts b/src/app/services/events.ts new file mode 100644 index 00000000..e7489d4f --- /dev/null +++ b/src/app/services/events.ts @@ -0,0 +1,174 @@ +import { getClient } from "./github"; +import { onAuthCleared } from "../stores/auth"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface GitHubEvent { + id: string; + type: string; + actor: { id: number; login: string }; + repo: { id: number; name: string }; // "owner/repo" format + payload: Record; + created_at: string; +} + +export interface RepoEventSummary { + repoFullName: string; // "owner/repo" + eventTypes: Set; // which event types fired + hasIssueActivity: boolean; + hasPRActivity: boolean; + hasWorkflowActivity: boolean; // PushEvent can trigger workflows + latestEventAt: string; // ISO timestamp of newest event +} + +// PullRequestReviewEvent presence on the user events endpoint is unverified; +// included optimistically — it's harmless if absent. +export const ACTIONABLE_EVENT_TYPES = [ + "IssuesEvent", + "IssueCommentEvent", + "PullRequestEvent", + "PullRequestReviewEvent", + "PullRequestReviewCommentEvent", + "PushEvent", +] as const; + +// ── Module-level ETag state ─────────────────────────────────────────────────── + +let _eventsETag: string | null = null; +let _lastEventId: string | null = null; + +// ── Auth cleanup ────────────────────────────────────────────────────────────── + +export function resetEventsState(): void { + _eventsETag = null; + _lastEventId = null; +} + +// Self-contained cleanup — same pattern as api-usage.ts onAuthCleared registration +onAuthCleared(resetEventsState); + +// ── fetchUserEvents ─────────────────────────────────────────────────────────── + +type GitHubOctokit = NonNullable>; + +export async function fetchUserEvents( + octokit: GitHubOctokit, + username: string, +): Promise<{ events: GitHubEvent[]; changed: boolean }> { + // Empty login would hit the public /users//events endpoint + if (!username) { + return { events: [], changed: false }; + } + + const headers: Record = {}; + if (_eventsETag) { + headers["If-None-Match"] = _eventsETag; + } + + try { + const response = await octokit.request("GET /users/{username}/events", { + username, + per_page: 100, + headers, + }); + + // Store ETag for next conditional request + const etag = (response.headers as Record)["etag"]; + if (etag) { + _eventsETag = etag; + } + + const allEvents = (response.data as GitHubEvent[]); + + // First call: no ID filter — seed _lastEventId and return all events + if (_lastEventId === null) { + if (allEvents.length > 0) { + _lastEventId = allEvents[0].id; // events are newest-first + } + return { events: allEvents, changed: allEvents.length > 0 }; + } + + // Subsequent calls: filter to only events newer than _lastEventId + // Use numeric comparison — event IDs are numeric strings; lexicographic + // comparison would break for IDs of different lengths (e.g. "9" > "10"). + const lastIdNum = parseInt(_lastEventId, 10); + const newEvents = allEvents.filter( + (e) => parseInt(e.id, 10) > lastIdNum, + ); + + if (newEvents.length > 0) { + _lastEventId = allEvents[0].id; // newest event is always first + } + + return { events: newEvents, changed: newEvents.length > 0 }; + } catch (err) { + // Octokit throws RequestError on 304 — same pattern as hasNotificationChanges() + if ( + typeof err === "object" && + err !== null && + (err as { status?: number }).status === 304 + ) { + return { events: [], changed: false }; + } + // Silent fallback for all other errors — full refresh handles reconciliation + console.warn("[events] fetchUserEvents error:", err instanceof Error ? err.message : String(err)); + return { events: [], changed: false }; + } +} + +// ── parseRepoEvents ─────────────────────────────────────────────────────────── + +const ACTIONABLE_SET = new Set(ACTIONABLE_EVENT_TYPES); + +export function parseRepoEvents( + events: GitHubEvent[], + trackedRepoNames: Set, +): Map { + const result = new Map(); + + for (const event of events) { + if (!ACTIONABLE_SET.has(event.type)) continue; + + const repoNameLower = event.repo.name.toLowerCase(); + if (!trackedRepoNames.has(repoNameLower)) continue; + + // Use the canonical casing from the event payload + const repoFullName = event.repo.name; + + let summary = result.get(repoNameLower); + if (!summary) { + summary = { + repoFullName, + eventTypes: new Set(), + hasIssueActivity: false, + hasPRActivity: false, + hasWorkflowActivity: false, + latestEventAt: event.created_at, + }; + result.set(repoNameLower, summary); + } + + summary.eventTypes.add(event.type); + + if (event.type === "IssuesEvent" || event.type === "IssueCommentEvent") { + summary.hasIssueActivity = true; + } + if ( + event.type === "PullRequestEvent" || + event.type === "PullRequestReviewEvent" || + event.type === "PullRequestReviewCommentEvent" + ) { + summary.hasPRActivity = true; + } + if (event.type === "PushEvent") { + summary.hasWorkflowActivity = true; + } + + // Track latest timestamp (events are newest-first, but don't assume order) + if (event.created_at > summary.latestEventAt) { + summary.latestEventAt = event.created_at; + } + } + + return result; +} diff --git a/src/app/services/jira-client.ts b/src/app/services/jira-client.ts new file mode 100644 index 00000000..97177aca --- /dev/null +++ b/src/app/services/jira-client.ts @@ -0,0 +1,220 @@ +import type { JiraIssue, JiraSearchResult, JiraBulkFetchResult, JiraAccessibleResource } from "../../shared/jira-types"; + +const DEFAULT_FIELDS = ["summary", "status", "priority", "assignee", "project", "updated", "issuetype", "created"]; + +// ── Error classes ───────────────────────────────────────────────────────────── + +export class JiraApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + message: string + ) { + super(message); + this.name = "JiraApiError"; + } +} + +export class JiraRateLimitError extends Error { + constructor(public readonly retryAfterSeconds: number) { + super(`Jira rate limit exceeded. Retry after ${retryAfterSeconds}s`); + this.name = "JiraRateLimitError"; + } +} + +// ── Interface ───────────────────────────────────────────────────────────────── + +export interface IJiraClient { + getIssue(key: string, fields?: string[]): Promise; + searchJql(jql: string, opts?: { maxResults?: number; fields?: string[]; startAt?: number }): Promise; + bulkFetch(keys: string[], fields?: string[]): Promise; +} + +// ── JiraClient (OAuth / Bearer) ─────────────────────────────────────────────── + +export class JiraClient implements IJiraClient { + private readonly baseUrl: string; + + constructor( + cloudId: string, + private readonly getAccessToken: () => Promise + ) { + this.baseUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3`; + } + + private async request(path: string, init: RequestInit = {}): Promise { + const accessToken = await this.getAccessToken(); + const url = `${this.baseUrl}${path}`; + const response = await fetch(url, { + ...init, + redirect: "error", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, + }); + + if (response.status === 429) { + const retryAfter = parseInt(response.headers.get("Retry-After") ?? "60", 10); + throw new JiraRateLimitError(Number.isFinite(retryAfter) ? retryAfter : 60); + } + + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + body = await response.text().catch(() => null); + } + throw new JiraApiError(response.status, body, `Jira API error ${response.status}`); + } + + return response.json() as Promise; + } + + async getIssue(key: string, fields: string[] = DEFAULT_FIELDS): Promise { + try { + return await this.request(`/issue/${encodeURIComponent(key)}?fields=${fields.join(",")}`); + } catch (err) { + if (err instanceof JiraApiError && err.status === 404) return null; + throw err; + } + } + + async searchJql( + jql: string, + opts: { maxResults?: number; fields?: string[]; startAt?: number } = {} + ): Promise { + const { maxResults = 100, fields = DEFAULT_FIELDS, startAt = 0 } = opts; + const params = new URLSearchParams({ + jql, + maxResults: String(maxResults), + startAt: String(startAt), + fields: fields.join(","), + }); + return this.request(`/search/jql?${params.toString()}`); + } + + async bulkFetch(keys: string[], fields: string[] = DEFAULT_FIELDS): Promise { + return this.request("/issue/bulkfetch", { + method: "POST", + body: JSON.stringify({ issueIdsOrKeys: keys, fields }), + }); + } + + static async getAccessibleResources(accessToken: string): Promise { + const response = await fetch("https://api.atlassian.com/oauth/token/accessible-resources", { + redirect: "error", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + }, + }); + + if (response.status === 429) { + const retryAfter = parseInt(response.headers.get("Retry-After") ?? "60", 10); + throw new JiraRateLimitError(Number.isFinite(retryAfter) ? retryAfter : 60); + } + + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + body = await response.text().catch(() => null); + } + throw new JiraApiError(response.status, body, `Jira accessible resources error ${response.status}`); + } + + return response.json() as Promise; + } +} + +// ── JiraProxyClient (API token / Worker proxy) ──────────────────────────────── + +export class JiraProxyClient implements IJiraClient { + constructor( + private readonly cloudId: string, + private readonly email: string, + private readonly sealed: string, + private readonly onResealed?: (resealed: string) => void + ) {} + + private async request( + endpoint: "search" | "issue", + params: Record + ): Promise { + const response = await fetch("/api/jira/proxy", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "fetch", + }, + body: JSON.stringify({ + endpoint, + cloudId: this.cloudId, + email: this.email, + sealed: this.sealed, + params, + }), + }); + + if (response.status === 429) { + const retryAfter = parseInt(response.headers.get("Retry-After") ?? "60", 10); + throw new JiraRateLimitError(Number.isFinite(retryAfter) ? retryAfter : 60); + } + + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + body = await response.text().catch(() => null); + } + throw new JiraApiError(response.status, body, `Jira proxy error ${response.status}`); + } + + return response.json() as Promise; + } + + async getIssue(key: string, fields: string[] = DEFAULT_FIELDS): Promise { + const result = await this.bulkFetch([key], fields); + if (result.issues.length === 0) return null; + const hasError = result.errors?.some((e) => e.issueIdsOrKeys.includes(key)); + if (hasError) return null; + const issue = result.issues[0] ?? null; + if (issue && issue.key !== key) return null; + return issue; + } + + async searchJql( + jql: string, + opts: { maxResults?: number; fields?: string[]; startAt?: number } = {} + ): Promise { + const { maxResults = 100, fields = DEFAULT_FIELDS, startAt = 0 } = opts; + const result = await this.request("search", { jql, maxResults, fields, startAt }); + if (result.resealed && this.onResealed) { + this.onResealed(result.resealed); + } + return { + issues: result.issues, + total: result.total, + maxResults: result.maxResults, + startAt: result.startAt, + ...(result.nextPageToken !== undefined ? { nextPageToken: result.nextPageToken } : {}), + }; + } + + async bulkFetch(keys: string[], fields: string[] = DEFAULT_FIELDS): Promise { + const result = await this.request("issue", { + issueIdsOrKeys: keys, + fields, + }); + if (result.resealed && this.onResealed) { + this.onResealed(result.resealed); + } + return { issues: result.issues, errors: result.errors }; + } +} diff --git a/src/app/services/jira-keys.ts b/src/app/services/jira-keys.ts new file mode 100644 index 00000000..9dc895b1 --- /dev/null +++ b/src/app/services/jira-keys.ts @@ -0,0 +1,95 @@ +import type { IJiraClient } from "./jira-client"; +import { JiraApiError } from "./jira-client"; +import type { JiraIssue } from "../../shared/jira-types"; +import { extractJiraKeys } from "../../shared/validation"; + +// Plain Map — not a module-level SolidJS signal (avoids cross-test pollution) +let _jiraKeyCache = new Map(); +const JIRA_KEY_CACHE_CAP = 500; + +export function clearJiraKeyCache(): void { + _jiraKeyCache = new Map(); +} + +// Evict oldest entries to make room — call before writing `incoming` new entries. +function evictToFit(incoming: number): void { + const excess = _jiraKeyCache.size + incoming - JIRA_KEY_CACHE_CAP; + if (excess <= 0) return; + const iter = _jiraKeyCache.keys(); + for (let i = 0; i < excess; i++) { + const key = iter.next().value; + if (key !== undefined) _jiraKeyCache.delete(key); + } +} + +export async function lookupKeys( + keys: string[], + client: IJiraClient +): Promise> { + if (keys.length === 0) return new Map(); + + const uncached = keys.filter((k) => !_jiraKeyCache.has(k)); + + if (uncached.length > 0) { + try { + // bulkFetch batches all uncached keys in a single round-trip + const result = await client.bulkFetch(uncached); + const byKey = new Map(result.issues.map((i) => [i.key, i])); + + // Mark errors as null (not found / inaccessible) + const errored = new Set( + (result.errors ?? []).flatMap((e) => e.issueIdsOrKeys) + ); + + evictToFit(uncached.length); + for (const key of uncached) { + if (errored.has(key)) { + _jiraKeyCache.set(key, null); + } else if (byKey.has(key)) { + _jiraKeyCache.set(key, byKey.get(key)!); + } else { + _jiraKeyCache.set(key, null); + } + } + } catch (err) { + if (err instanceof JiraApiError) { + // Cache null for all keys in the failed batch — don't throw, return partial map + evictToFit(uncached.length); + for (const key of uncached) { + _jiraKeyCache.set(key, null); + } + } else { + // Network error — fall back to concurrent individual getIssue calls + // (CORS for POST to api.atlassian.com with OAuth Bearer is verified working) + const results = await Promise.allSettled( + uncached.map((k) => client.getIssue(k)) + ); + evictToFit(uncached.length); + for (let i = 0; i < uncached.length; i++) { + const r = results[i]; + _jiraKeyCache.set(uncached[i], r.status === "fulfilled" ? r.value : null); + } + } + } + } + + const result = new Map(); + for (const k of keys) { + if (_jiraKeyCache.has(k)) result.set(k, _jiraKeyCache.get(k)!); + } + return result; +} + +export async function detectAndLookupJiraKeys( + items: Array<{ title: string; headRef?: string }>, + client: IJiraClient +): Promise> { + const allKeys = new Set(); + for (const item of items) { + for (const k of extractJiraKeys(item.title)) allKeys.add(k); + if (item.headRef) { + for (const k of extractJiraKeys(item.headRef)) allKeys.add(k); + } + } + return lookupKeys([...allKeys], client); +} diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index c42fa8de..bbd7a9b0 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -18,8 +18,9 @@ import { type HotWorkflowRunUpdate, resetEmptyActionRepos, } from "./api"; +import { fetchUserEvents, parseRepoEvents, resetEventsState, type RepoEventSummary } from "./events"; import { detectNewItems, dispatchNotifications, _resetNotificationState } from "../lib/notifications"; -import { pushError, pushNotification, getNotifications, dismissNotificationBySource, startCycleTracking, endCycleTracking, resetNotificationState } from "../lib/errors"; +import { pushError, getNotifications, dismissNotificationBySource, startCycleTracking, endCycleTracking, resetNotificationState } from "../lib/errors"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -28,8 +29,6 @@ export interface DashboardData { pullRequests: PullRequest[]; workflowRuns: WorkflowRun[]; errors: ApiError[]; - /** True when notifications gate determined nothing changed — consumer should keep existing data */ - skipped?: boolean; } export interface PollCoordinator { @@ -39,11 +38,6 @@ export interface PollCoordinator { destroy: () => void; } -// ── Notifications gate ─────────────────────────────────────────────────────── - -let _notifLastModified: string | null = null; -let _notifGateDisabled = false; // Disabled after 403 (notifications scope not granted) - // ── Hot poll state ──────────────────────────────────────────────────────────── /** PRs with pending/null check status: maps GraphQL node ID → databaseId */ @@ -73,16 +67,7 @@ export function clearHotSets(): void { _hotRuns.clear(); } -/** Simulate 403 on /notifications — disables the notifications gate. - * Used by tests to exercise the conditional background-poll guard. */ -export function disableNotifGate(): void { - _notifGateDisabled = true; -} - export function resetPollState(): void { - _notifLastModified = null; - _lastSuccessfulFetch = null; - _notifGateDisabled = false; _hotPRs.clear(); _hotPRsByDbId.clear(); _hotRuns.clear(); @@ -90,6 +75,8 @@ export function resetPollState(): void { _resetNotificationState(); resetEmptyActionRepos(); resetNotificationState(); + resetEventsState(); + _repoLastTargeted.clear(); } // Auto-reset poll state on logout (avoids circular dep with auth.ts) @@ -103,11 +90,26 @@ onAuthCleared(resetPollState); // NOTE: Mount flags are intentionally permanent (module lifetime) and NOT cleared // by resetPollState(). The createRoot runs once at module load; the effects // continue tracking config changes across auth cycles without re-mounting. +let _userLoginMounted = false; +let _userLoginKey = ""; let _trackedUsersMounted = false; let _trackedUsersKey = ""; let _monitoredReposMounted = false; let _monitoredReposKey = ""; createRoot(() => { + createEffect(() => { + const key = user()?.login ?? ""; + if (!_userLoginMounted) { + _userLoginMounted = true; + _userLoginKey = key; + return; + } + if (key !== _userLoginKey) { + _userLoginKey = key; + untrack(() => resetEventsState()); + } + }); + createEffect(() => { const key = (config.trackedUsers ?? []).map((u) => u.login).sort().join(","); if (!_trackedUsersMounted) { @@ -117,8 +119,10 @@ createRoot(() => { } if (key !== _trackedUsersKey) { _trackedUsersKey = key; - _lastSuccessfulFetch = null; // Force next poll to bypass notifications gate - untrack(() => _resetNotificationState()); + untrack(() => { + _resetNotificationState(); + resetEventsState(); + }); } }); @@ -131,79 +135,14 @@ createRoot(() => { } if (key !== _monitoredReposKey) { _monitoredReposKey = key; - _lastSuccessfulFetch = null; // Force next poll to bypass notifications gate - untrack(() => _resetNotificationState()); + untrack(() => { + _resetNotificationState(); + resetEventsState(); + }); } }); }); -/** - * Checks if anything changed since last poll using the Notifications API. - * Returns true if there are new notifications (or first check), false if unchanged. - * Uses If-Modified-Since for zero-cost 304 checks (doesn't count against rate limit). - * - * Auto-disables after a 403 (notifications scope not granted) to stop wasting - * rate limit tokens on requests that will always fail. - */ -async function hasNotificationChanges(): Promise { - if (_notifGateDisabled) return true; - - const octokit = getClient(); - if (!octokit) return true; - - try { - const headers: Record = {}; - if (_notifLastModified) { - headers["If-Modified-Since"] = _notifLastModified; - } - - const response = await octokit.request("GET /notifications", { - per_page: 1, - headers, - }); - - // Store Last-Modified for next conditional request - const lastMod = (response.headers as Record)["last-modified"]; - if (lastMod) { - _notifLastModified = lastMod; - } - - return true; // 200 = something changed - } catch (err) { - // 304 and 403 are still real API calls — tracked automatically by the hook - if ( - typeof err === "object" && - err !== null && - (err as { status?: number }).status === 304 - ) { - return false; // Nothing changed since last check - } - // 403 = notifications scope not granted — disable gate permanently - // to stop burning rate limit tokens on every poll cycle - if ( - typeof err === "object" && - err !== null && - (err as { status?: number }).status === 403 - ) { - console.warn("[poll] Notifications API returned 403 — disabling gate"); - pushNotification("notifications", config.authMethod === "pat" - ? "Notifications API returned 403 — fine-grained tokens do not support notifications; classic tokens need the notifications scope. Background refresh in hidden tabs is disabled." - : "Notifications API returned 403 — check that the notifications scope is granted. Background refresh in hidden tabs is disabled.", "warning"); - _notifGateDisabled = true; - } - return true; - } -} - -// ── Incremental fetch timestamps ───────────────────────────────────────────── - -let _lastSuccessfulFetch: Date | null = null; - -// Force a full fetch if the notifications gate has been skipping for too long. -// Notifications don't cover all change types (e.g., workflow runs on unwatched -// repos, label changes without notification), so we cap staleness. -const MAX_GATE_STALENESS_MS = 10 * 60 * 1000; // 10 minutes - // ── fetchAllData orchestrator ───────────────────────────────────────────────── /** @@ -217,20 +156,7 @@ export async function fetchAllData( ): Promise { const octokit = getClient(); if (!octokit) { - return { issues: [], pullRequests: [], workflowRuns: [], errors: [], skipped: true }; - } - - // On subsequent polls, check notifications first (free when 304) - if (_lastSuccessfulFetch) { - const staleness = Date.now() - _lastSuccessfulFetch.getTime(); - if (staleness < MAX_GATE_STALENESS_MS) { - const changed = await hasNotificationChanges(); - if (!changed) { - console.info("[poll] No notification changes — skipping full fetch"); - return { issues: [], pullRequests: [], workflowRuns: [], errors: [], skipped: true }; - } - } - // If staleness >= MAX_GATE_STALENESS_MS, skip the gate and force a full fetch + return { issues: [], pullRequests: [], workflowRuns: [], errors: [] }; } const userLogin = user()?.login ?? ""; @@ -298,14 +224,6 @@ export async function fetchAllData( ...(runData?.errors ?? []), ]; - // Only activate the notifications gate if at least one fetch succeeded. - // If all failed (e.g., network outage), we don't want the gate to - // suppress retries on the next poll cycle. - const anySucceeded = issuesAndPrsData !== null || runData !== null; - if (anySucceeded) { - _lastSuccessfulFetch = new Date(); - } - return { issues: issuesAndPrsData?.issues ?? [], pullRequests: issuesAndPrsData?.pullRequests ?? [], @@ -320,7 +238,7 @@ const REJITTER_WINDOW_MS = 30_000; // ±30 seconds jitter const REVISIT_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes // Sources managed by the poll coordinator — used for reconciliation -const POLL_MANAGED_SOURCES = new Set(["poll", "graphql", "rate-limit", "notifications", "search/issues", "search/prs"]); +const POLL_MANAGED_SOURCES = new Set(["poll", "graphql", "rate-limit", "search/issues", "search/prs"]); function withJitter(intervalMs: number): number { const jitter = (Math.random() * 2 - 1) * REJITTER_WINDOW_MS; @@ -332,10 +250,7 @@ function withJitter(intervalMs: number): number { * - Triggers an immediate fetch on init * - Polls at getInterval() seconds (reactive — restarts when interval changes) * - If getInterval() === 0, disables auto-polling - * - Continues polling in background tabs when notifications gate is available - * (304 responses make background polls near-zero cost). When the gate is - * disabled (fine-grained PAT or missing notifications scope), background - * polling pauses to conserve API budget. + * - Skips background polls when hidden (GraphQL POST has no 304 shortcut) * - On re-visible after >2 min hidden, fires catch-up fetch (safety net for * browser tab throttling/freezing — Safari purge, Chrome Energy Saver) * - Applies ±30 second jitter to poll interval @@ -352,9 +267,14 @@ export function createPollCoordinator( let intervalId: ReturnType | null = null; let hiddenAt: number | null = null; let destroyed = false; + let pendingForce = false; - async function doFetch(): Promise { - if (destroyed || isRefreshing()) return; + async function doFetch(force = false): Promise { + if (destroyed) return; + if (isRefreshing()) { + if (force) pendingForce = true; + return; + } checkAndResetIfExpired(); setIsRefreshing(true); // Fire-and-forget: seeds footer signals concurrently with fetchAll. If GET /rate_limit @@ -371,7 +291,6 @@ export function createPollCoordinator( try { const data = await fetchAll(); - if (data.skipped) return; // finally handles endCycleTracking + setIsRefreshing setLastRefreshAt(new Date()); // Surface per-repo API errors globally for (const err of data.errors) { @@ -388,11 +307,16 @@ export function createPollCoordinator( dispatchNotifications(newItems, config); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error during data fetch"; + Sentry.captureException(err, { tags: { source: "poll-cycle" } }); pushError("poll", message, true); // No reconciliation on catch — can't know what resolved } finally { endCycleTracking(); // Safe to call twice (returns empty Set if already ended) setIsRefreshing(false); + if (pendingForce) { + pendingForce = false; + void doFetch(true); + } } } @@ -409,20 +333,18 @@ export function createPollCoordinator( const intervalMs = withJitter(intervalSec * 1000); intervalId = setInterval(() => { - // Without the notifications gate (403 — scope not granted), every background - // poll is a full fetch with no 304 shortcut. Skip background polls to avoid - // burning API budget; the catch-up handler still fires on tab return. - if (document.visibilityState === "hidden" && _notifGateDisabled) return; + // Full refresh (GraphQL POST) has no 304 shortcut — skip background tabs + // to avoid burning API budget. The catch-up handler fires on tab return, + // and the events poll continues in background tabs (ETag 304 = zero cost). + if (document.visibilityState === "hidden") return; void doFetch(); }, intervalMs); } - // Safety net for browser-level tab throttling/freezing. Background polling - // continues via setInterval, but browsers may throttle or freeze timers in - // hidden tabs (Chrome Energy Saver, Safari tab purge, Firefox timer capping). - // When the tab becomes visible again after >2 min, this handler fires a - // catch-up fetch in case the browser suppressed scheduled polls. The - // notifications gate (304) makes redundant fetches near-zero cost. + // Safety net for browser-level tab throttling/freezing. Background polls are + // skipped (no 304 shortcut for GraphQL), but browsers may also freeze hidden + // tab timers (Chrome Energy Saver, Safari tab purge, Firefox timer capping). + // When the tab becomes visible again after >2 min, fire a catch-up fetch. function handleVisibilityChange(): void { if (document.visibilityState === "hidden") { hiddenAt = Date.now(); @@ -455,6 +377,7 @@ export function createPollCoordinator( function destroy(): void { destroyed = true; + pendingForce = false; clearTimer(); document.removeEventListener("visibilitychange", handleVisibilityChange); } @@ -462,7 +385,7 @@ export function createPollCoordinator( onCleanup(destroy); function manualRefresh(): void { - void doFetch(); + void doFetch(true); // Reset interval timer so next auto-poll is a full interval from now const currentInterval = getInterval(); if (currentInterval > 0) { @@ -727,3 +650,225 @@ export function createHotPollCoordinator( return { destroy }; } + +// ── Targeted refresh (events-driven) ───────────────────────────────────────── + +const MAX_TARGETED_REPOS = 10; +const TARGETED_COOLDOWN_MS = 2 * 60 * 1000; +const _repoLastTargeted = new Map(); + +export async function fetchTargetedRepoData( + repoSummaries: Map, +): Promise { + const octokit = getClient(); + if (!octokit) { + return { issues: [], pullRequests: [], workflowRuns: [], errors: [] }; + } + + const userLogin = user()?.login ?? ""; + + // Skip repos refreshed recently — prevents API amplification when multiple events fire for the same repo + const now = Date.now(); + let entries = [...repoSummaries.entries()].filter(([key]) => { + const lastTargeted = _repoLastTargeted.get(key); + return !lastTargeted || (now - lastTargeted) >= TARGETED_COOLDOWN_MS; + }); + + // Cap targeted repos per cycle — prioritize by most recent event to focus on active work + if (entries.length > MAX_TARGETED_REPOS) { + entries.sort((a, b) => b[1].latestEventAt.localeCompare(a[1].latestEventAt)); + entries = entries.slice(0, MAX_TARGETED_REPOS); + } + + if (entries.length === 0) { + return { issues: [], pullRequests: [], workflowRuns: [], errors: [] }; + } + + // Record cooldown timestamps + for (const [key] of entries) { + _repoLastTargeted.set(key, now); + } + + const targetRepos = entries + .map(([, summary]) => { + const parts = summary.repoFullName.split("/"); + if (parts.length !== 2) return null; + return { owner: parts[0], name: parts[1], fullName: summary.repoFullName }; + }) + .filter((r): r is NonNullable => r !== null); + + const workflowRepos = entries + .filter(([, summary]) => summary.hasWorkflowActivity) + .map(([, summary]) => { + const parts = summary.repoFullName.split("/"); + if (parts.length !== 2) return null; + return { owner: parts[0], name: parts[1], fullName: summary.repoFullName }; + }) + .filter((r): r is NonNullable => r !== null); + + const [issuesAndPrsResult, runResult] = await Promise.allSettled([ + fetchIssuesAndPullRequests(octokit, targetRepos, userLogin), + workflowRepos.length > 0 + ? fetchWorkflowRuns(octokit, workflowRepos, config.maxWorkflowsPerRepo, config.maxRunsPerWorkflow) + : Promise.resolve({ workflowRuns: [] as WorkflowRun[], errors: [] as ApiError[] }), + ]); + + const errors: ApiError[] = []; + if (issuesAndPrsResult.status === "rejected") { + const err = issuesAndPrsResult.reason; + errors.push({ repo: "targeted-issues", statusCode: null, message: err instanceof Error ? err.message : String(err), retryable: true }); + } + if (runResult.status === "rejected") { + const err = runResult.reason; + errors.push({ repo: "targeted-runs", statusCode: null, message: err instanceof Error ? err.message : String(err), retryable: true }); + } + + const issuesAndPrsData = issuesAndPrsResult.status === "fulfilled" ? issuesAndPrsResult.value : null; + const runData = runResult.status === "fulfilled" ? runResult.value : null; + + return { + issues: issuesAndPrsData?.issues ?? [], + pullRequests: issuesAndPrsData?.pullRequests ?? [], + workflowRuns: runData?.workflowRuns ?? [], + errors: [...errors, ...(issuesAndPrsData?.errors ?? []), ...(runData?.errors ?? [])], + }; +} + +// ── Hot set seeding from targeted refresh ──────────────────────────────────── + +export function seedHotSetsFromTargeted(data: DashboardData): void { + for (const pr of data.pullRequests) { + if (pr.enriched && pr.checkStatus === "pending" && pr.nodeId) { + if (_hotPRs.size >= MAX_HOT_PRS) break; + if (!_hotPRs.has(pr.nodeId)) { + _hotPRs.set(pr.nodeId, pr.id); + _hotPRsByDbId.set(pr.id, pr.nodeId); + } + } + } + + for (const run of data.workflowRuns) { + if (run.status === "queued" || run.status === "in_progress") { + if (_hotRuns.size >= MAX_HOT_RUNS) break; + if (!_hotRuns.has(run.id)) { + const parts = run.repoFullName.split("/"); + if (parts.length === 2) { + _hotRuns.set(run.id, { owner: parts[0], repo: parts[1] }); + } + } + } + } +} + +// ── Events poll coordinator ────────────────────────────────────────────────── + +// Fixed at 60s: GitHub's Events API has a ~60s server-side cache, so polling +// more frequently returns stale data and wastes rate-limit quota. +const EVENTS_POLL_INTERVAL_MS = 60_000; + +export function createEventsPollCoordinator( + getUsername: () => string, + getTrackedRepoNames: () => Set, + isFullRefreshing: () => boolean, + onTargetedData: (data: DashboardData, affectedRepos: string[]) => void, +): { destroy: () => void } { + let timeoutId: ReturnType | null = null; + let chainGeneration = 0; + let consecutiveFailures = 0; + const MAX_BACKOFF_MULTIPLIER = 8; + + function destroy(): void { + chainGeneration++; + consecutiveFailures = 0; + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + } + + function schedule(myGeneration: number, delayMs: number): void { + if (myGeneration !== chainGeneration) return; + const backoff = Math.min(2 ** consecutiveFailures, MAX_BACKOFF_MULTIPLIER); + timeoutId = setTimeout(() => void cycle(myGeneration), delayMs * backoff); + } + + async function cycle(myGeneration: number): Promise { + if (myGeneration !== chainGeneration) return; + + const username = getUsername(); + if (!username) { + consecutiveFailures = 0; + schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); + return; + } + + const octokit = getClient(); + if (!octokit) { + consecutiveFailures = 0; + schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); + return; + } + + if (isFullRefreshing()) { + consecutiveFailures = 0; + schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); + return; + } + + try { + const { events, changed } = await fetchUserEvents(octokit, username); + if (myGeneration !== chainGeneration) return; + + if (!changed || events.length === 0) { + consecutiveFailures = 0; + schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); + return; + } + + const repoSummaries = parseRepoEvents(events, getTrackedRepoNames()); + if (repoSummaries.size === 0) { + consecutiveFailures = 0; + schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); + return; + } + + if (isFullRefreshing()) { + consecutiveFailures = 0; + schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); + return; + } + + const preGeneration = getHotPollGeneration(); + + const data = await fetchTargetedRepoData(repoSummaries); + if (myGeneration !== chainGeneration) return; + + if (preGeneration !== getHotPollGeneration()) { + consecutiveFailures = 0; + schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); + return; + } + + if (isFullRefreshing()) { + consecutiveFailures = 0; + schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); + return; + } + + const affectedRepos = [...repoSummaries.values()].map((s) => s.repoFullName); + onTargetedData(data, affectedRepos); + consecutiveFailures = 0; + } catch (err) { + consecutiveFailures++; + console.warn("[events-poll] cycle error:", err instanceof Error ? err.message : String(err)); + } + + schedule(myGeneration, EVENTS_POLL_INTERVAL_MS); + } + + // First cycle fires immediately (delay=0) to establish ETag baseline + const gen = chainGeneration; + timeoutId = setTimeout(() => void cycle(gen), 0); + + return { destroy }; +} diff --git a/src/app/stores/auth.ts b/src/app/stores/auth.ts index 174b1090..8fe6778d 100644 --- a/src/app/stores/auth.ts +++ b/src/app/stores/auth.ts @@ -1,12 +1,30 @@ import { createSignal } from "solid-js"; +import { z } from "zod"; import * as Sentry from "@sentry/solid"; import { clearCache } from "./cache"; import { CONFIG_STORAGE_KEY, resetConfig, updateConfig, config } from "./config"; import { VIEW_STORAGE_KEY, resetViewState } from "./view"; import { pushNotification } from "../lib/errors"; +import { clearJiraKeyCache } from "../services/jira-keys"; +import type { JiraAuthState } from "../../shared/jira-types"; +import { JiraConfigSchema } from "../../shared/schemas"; + +// Zod schema for JiraAuthState — validates the localStorage-persisted blob on load. +const JiraAuthStateSchema = z.object({ + accessToken: z.string().min(1), + sealedRefreshToken: z.string(), + expiresAt: z.number(), + cloudId: z.string().min(1), + siteUrl: z.string().url(), + siteName: z.string().min(1), + email: z.string().optional(), +}); + +export type { JiraAuthState } from "../../shared/jira-types"; export const AUTH_STORAGE_KEY = "github-tracker:auth-token"; export const DASHBOARD_STORAGE_KEY = "github-tracker:dashboard"; +export const JIRA_AUTH_STORAGE_KEY = "github-tracker:jira-auth"; export interface GitHubUser { login: string; @@ -39,6 +57,115 @@ export function isAuthenticated(): boolean { export { user }; +// ── Jira auth signals ──────────────────────────────────────────────────────── + +const [_jiraAuth, _setJiraAuth] = createSignal( + (() => { + try { + const raw = localStorage.getItem?.(JIRA_AUTH_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + const result = JiraAuthStateSchema.safeParse(parsed); + if (!result.success) { + // Corrupt or outdated auth blob — evict it so user is prompted to reconnect + localStorage.removeItem?.(JIRA_AUTH_STORAGE_KEY); + return null; + } + return result.data as JiraAuthState; + } catch { + return null; + } + })() +); + +export const jiraAuth = _jiraAuth; + +export function isJiraAuthenticated(): boolean { + return _jiraAuth() !== null; +} + +export function setJiraAuth(state: JiraAuthState): void { + try { + localStorage.setItem(JIRA_AUTH_STORAGE_KEY, JSON.stringify(state)); + } catch { + pushNotification("localStorage:jira-auth", "Jira auth write failed — storage may be full. Auth exists in memory only this session.", "warning"); + } + _setJiraAuth(state); +} + +export function clearJiraAuth(): void { + localStorage.removeItem(JIRA_AUTH_STORAGE_KEY); + _setJiraAuth(null); + updateConfig({ jira: JiraConfigSchema.parse({}) }); + clearJiraKeyCache(); +} + +// ── Jira token refresh ─────────────────────────────────────────────────────── + +let _refreshingJira: Promise | null = null; + +export async function ensureJiraTokenValid(): Promise { + const auth = _jiraAuth(); + if (!auth) return false; + + // API token mode: two explicit guards prevent refresh (authMethod check, + // empty sealedRefreshToken). Token auth sets expiresAt = MAX_SAFE_INTEGER, + // so the expiry arithmetic below also short-circuits, but implicitly. + if (config.jira?.authMethod === "token") return true; + if (!auth.sealedRefreshToken) return true; + + if (auth.expiresAt >= Date.now() + 300_000) return true; + + // Single-flight guard: concurrent calls share one refresh promise + if (_refreshingJira !== null) return _refreshingJira; + + _refreshingJira = (async (): Promise => { + try { + let resp: Response; + try { + resp = await fetch("/api/oauth/jira/refresh", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Requested-With": "fetch" }, + body: JSON.stringify({ sealed_refresh_token: auth.sealedRefreshToken }), + }); + } catch { + // Network error — preserve tokens, transient failure + return false; + } + + if (resp.status === 401) { + // Refresh token expired or revoked + clearJiraAuth(); + pushNotification("jira:refresh", "Jira session expired — please reconnect in Settings.", "warning"); + return false; + } + + if (!resp.ok) return false; + + const data = (await resp.json()) as { access_token: string; sealed_refresh_token: string; expires_in: number }; + if (!data.access_token || !data.sealed_refresh_token) return false; + + const current = _jiraAuth(); + if (!current) return false; + + const expiresIn = typeof data.expires_in === "number" && data.expires_in > 0 + ? data.expires_in + : 3600; + setJiraAuth({ + ...current, + accessToken: data.access_token, + sealedRefreshToken: data.sealed_refresh_token, + expiresAt: Date.now() + expiresIn * 1000, + }); + return true; + } finally { + _refreshingJira = null; + } + })(); + + return _refreshingJira; +} + // ── Actions ───────────────────────────────────────────────────────────────── export function setAuth(response: TokenExchangeResponse): void { @@ -179,8 +306,19 @@ export async function validateToken(): Promise { } } +// Register Jira auth cleanup when GitHub auth is cleared (full logout). +// Only clears localStorage + signal — does NOT call updateConfig because +// clearAuth() already calls resetConfig() before firing these callbacks, +// so all config fields (including jira.*) are already reset to their defaults. +onAuthCleared(() => { + localStorage.removeItem(JIRA_AUTH_STORAGE_KEY); + _setJiraAuth(null); +}); + // Cross-tab auth sync: if another tab clears the token, this tab should also clear. // Uses expireToken() (not clearAuth()) to avoid wiping config/view that may still be valid. +// Also syncs Jira auth across tabs — critical for rotating refresh tokens: a stale tab +// holding an already-invalidated token would fail on its next Jira request. if (typeof window !== "undefined") { window.addEventListener("storage", (e: StorageEvent) => { if (e.key === AUTH_STORAGE_KEY && e.newValue === null && _token()) { @@ -189,5 +327,18 @@ if (typeof window !== "undefined") { expireToken(); window.location.replace("/login"); } + if (e.key === JIRA_AUTH_STORAGE_KEY) { + try { + const raw = e.newValue; + if (!raw) { + _setJiraAuth(null); + return; + } + const parsed = JiraAuthStateSchema.safeParse(JSON.parse(raw) as unknown); + _setJiraAuth(parsed.success ? parsed.data : null); + } catch { + _setJiraAuth(null); + } + } }); } diff --git a/src/app/stores/config.ts b/src/app/stores/config.ts index 75e7c33e..54dc6f75 100644 --- a/src/app/stores/config.ts +++ b/src/app/stores/config.ts @@ -3,7 +3,7 @@ import { createEffect, onCleanup } from "solid-js"; import { pushNotification } from "../lib/errors"; import { viewState, updateViewState } from "./view"; import { ConfigSchema, RepoRefSchema, THEME_OPTIONS, BUILTIN_TAB_IDS, CustomTabSchema } from "../../shared/schemas"; -import type { Config, ThemeId, CustomTab } from "../../shared/schemas"; +import type { Config, ThemeId, CustomTab, JiraConfig } from "../../shared/schemas"; import { z } from "zod"; // ── Re-exports from shared/schemas (backward compat for existing importers) ─── @@ -11,6 +11,7 @@ export { ConfigSchema, RepoRefSchema, TrackedUserSchema, THEME_OPTIONS, CustomTabSchema, BUILTIN_TAB_IDS, isBuiltinTab, type Config, type TrackedUser, type ThemeId, type CustomTab, type BuiltinTabId, + type JiraConfig, } from "../../shared/schemas"; export const CONFIG_STORAGE_KEY = "github-tracker:config"; @@ -101,6 +102,10 @@ export function setMcpRelayPort(port: number): void { updateConfig({ mcpRelayPort: port }); } +export function updateJiraConfig(partial: Partial): void { + updateConfig({ jira: { ...config.jira, ...partial } }); +} + export function resetConfig(): void { const defaults = ConfigSchema.parse({}); setConfig(defaults); diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 3a4894db..7c8b2cb1 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -10,11 +10,16 @@ export const LOCKED_REPOS_CAP = 50; export const TrackedItemSchema = z.object({ id: z.number(), - number: z.number(), - type: z.enum(["issue", "pullRequest"]), + number: z.number().optional(), + type: z.enum(["issue", "pullRequest", "jiraIssue"]), + source: z.enum(["github", "jira"]).default("github"), repoFullName: z.string(), title: z.string(), addedAt: z.number(), + jiraKey: z.string().optional(), + jiraProjectKey: z.string().optional(), + jiraStatus: z.string().optional(), + htmlUrl: z.string().optional(), }); export type TrackedItem = z.infer; @@ -41,12 +46,23 @@ export const ActionsFiltersSchema = z.object({ event: z.enum(["all", "push", "pull_request", "schedule", "workflow_dispatch", "other"]).default("all"), }); +// "done" intentionally excluded — JQL `statusCategory != Done` never returns Done items +export const JiraFiltersSchema = z.object({ + scope: z.enum(["assigned", "reported", "watching"]).default("assigned"), + statusCategory: z.enum(["all", "new", "indeterminate"]).default("all"), + priority: z.enum(["all", "Highest", "High", "Medium", "Low", "Lowest"]).default("all"), + sortField: z.string().default("status"), + sortDirection: z.enum(["asc", "desc"]).default("asc"), +}); + export type IssueFilters = z.infer; export type IssueFilterField = keyof IssueFilters; export type PullRequestFilters = z.infer; export type PullRequestFilterField = keyof PullRequestFilters; export type ActionsFilters = z.infer; export type ActionsFilterField = keyof ActionsFilters; +export type JiraFilters = z.infer; +export type JiraFilterField = keyof JiraFilters; export const ViewStateSchema = z.object({ lastActiveTab: z.string().default("issues"), @@ -76,10 +92,12 @@ export const ViewStateSchema = z.object({ issues: IssueFiltersSchema.default({ scope: "involves_me", role: "all", comments: "all", user: "all" }), pullRequests: PullRequestFiltersSchema.default({ scope: "involves_me", role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }), actions: ActionsFiltersSchema.default({ conclusion: "all", event: "all" }), + jiraAssigned: JiraFiltersSchema.default({ scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }), }).default({ issues: { scope: "involves_me", role: "all", comments: "all", user: "all" }, pullRequests: { scope: "involves_me", role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }, actions: { conclusion: "all", event: "all" }, + jiraAssigned: { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }, }), showPrRuns: z.boolean().default(false), hideDepDashboard: z.boolean().default(true), @@ -94,28 +112,29 @@ export const ViewStateSchema = z.object({ issues: {}, pullRequests: {}, actions: {}, + jiraAssigned: {}, }), - lockedRepos: z.record(z.string(), z.array(z.string().max(200)).max(LOCKED_REPOS_CAP)).default({ issues: [], pullRequests: [], actions: [] }), + lockedRepos: z.record(z.string(), z.array(z.string().max(200)).max(LOCKED_REPOS_CAP)).default({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }), trackedItems: z.array(TrackedItemSchema).max(TRACKED_ITEMS_CAP).default([]), }); export type ViewState = z.infer; export type IgnoredItem = ViewState["ignoredItems"][number]; -const REPO_STATE_TAB_IDS = ["issues", "pullRequests", "actions"] as const; +const REPO_STATE_TAB_IDS = ["issues", "pullRequests", "actions", "jiraAssigned"] as const; export function migrateLockedRepos(raw: unknown): unknown { - if (raw == null) return { issues: [], pullRequests: [], actions: [] }; + if (raw == null) return { issues: [], pullRequests: [], actions: [], jiraAssigned: [] }; if (Array.isArray(raw)) { - // Flat array → copy to all 3 built-in tabs + // Flat array → copy to all built-in tabs const arr = raw.filter((item): item is string => typeof item === "string").slice(0, LOCKED_REPOS_CAP); - return { issues: [...arr], pullRequests: [...arr], actions: [...arr] }; + return { issues: [...arr], pullRequests: [...arr], actions: [...arr], jiraAssigned: [] }; } if (typeof raw === "object") { // Object → pass through as-is; loadViewState cap-guard sanitizes malformed entries return raw; } - return { issues: [], pullRequests: [], actions: [] }; + return { issues: [], pullRequests: [], actions: [], jiraAssigned: [] }; } function loadViewState(): ViewState { @@ -179,12 +198,13 @@ export function resetViewState(): void { issues: { scope: "involves_me", role: "all", comments: "all", user: "all" }, pullRequests: { scope: "involves_me", role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }, actions: { conclusion: "all", event: "all" }, + jiraAssigned: { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }, }, showPrRuns: false, hideDepDashboard: true, customTabFilters: {}, - expandedRepos: { issues: {}, pullRequests: {}, actions: {} }, - lockedRepos: { issues: [], pullRequests: [], actions: [] }, + expandedRepos: { issues: {}, pullRequests: {}, actions: {}, jiraAssigned: {} }, + lockedRepos: { issues: [], pullRequests: [], actions: [], jiraAssigned: [] }, trackedItems: [], }); }) @@ -259,6 +279,7 @@ type TabFilterField = { issues: keyof IssueFilters; pullRequests: keyof PullRequestFilters; actions: keyof ActionsFilters; + jiraAssigned: keyof JiraFilters; }; export function setTabFilter( @@ -274,7 +295,7 @@ export function setTabFilter( } export function resetAllTabFilters( - tab: "issues" | "pullRequests" | "actions" + tab: "issues" | "pullRequests" | "actions" | "jiraAssigned" ): void { setViewState( produce((draft) => { @@ -282,6 +303,8 @@ export function resetAllTabFilters( draft.tabFilters.issues = IssueFiltersSchema.parse({}); } else if (tab === "pullRequests") { draft.tabFilters.pullRequests = PullRequestFiltersSchema.parse({}); + } else if (tab === "jiraAssigned") { + draft.tabFilters.jiraAssigned = JiraFiltersSchema.parse({}); } else { draft.tabFilters.actions = ActionsFiltersSchema.parse({}); } @@ -432,8 +455,11 @@ export function pruneLockedRepos( export function trackItem(item: TrackedItem): void { setViewState( produce((draft) => { - const already = draft.trackedItems.some( - (i) => i.id === item.id && i.type === item.type + // Jira items dedup by jiraKey (not id) — hash collisions are possible with 32-bit hash + const already = draft.trackedItems.some((i) => + item.source === "jira" + ? i.source === "jira" && i.jiraKey === item.jiraKey + : i.id === item.id && i.type === item.type ); if (!already) { // FIFO eviction: remove oldest if at cap @@ -446,6 +472,31 @@ export function trackItem(item: TrackedItem): void { ); } +export function untrackJiraItem(jiraKey: string): void { + setViewState( + produce((draft) => { + draft.trackedItems = draft.trackedItems.filter( + (i) => !(i.source === "jira" && i.jiraKey === jiraKey) + ); + }) + ); +} + +export function moveJiraItem(jiraKey: string, direction: "up" | "down"): void { + setViewState( + produce((draft) => { + const arr = draft.trackedItems; + const idx = arr.findIndex((i) => i.source === "jira" && i.jiraKey === jiraKey); + if (idx === -1) return; + const targetIdx = direction === "up" ? idx - 1 : idx + 1; + if (targetIdx < 0 || targetIdx >= arr.length) return; + const tmp = arr[idx]; + arr[idx] = arr[targetIdx]; + arr[targetIdx] = tmp; + }) + ); +} + export function untrackItem(id: number, type: "issue" | "pullRequest"): void { setViewState( produce((draft) => { diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts new file mode 100644 index 00000000..891a9ef0 --- /dev/null +++ b/src/shared/jira-types.ts @@ -0,0 +1,93 @@ +// ── Jira Cloud API response types ───────────────────────────────────────────── +// Shared between jira-client.ts and consuming components. + +export type JiraStatusCategory = "new" | "indeterminate" | "done"; + +export interface JiraStatus { + id: string; + name: string; + statusCategory: { + id: number; + key: JiraStatusCategory; + name: string; + }; +} + +export interface JiraPriority { + id: string; + name: string; + iconUrl?: string; +} + +export interface JiraUser { + accountId: string; + displayName: string; + emailAddress?: string; + avatarUrls?: Record; +} + +export interface JiraIssueFields { + summary: string; + status: JiraStatus; + priority: JiraPriority | null; + assignee: JiraUser | null; + project: { + id: string; + key: string; + name: string; + }; + created?: string; + updated?: string; + issuetype?: { + name: string; + iconUrl?: string; + }; + [key: string]: unknown; +} + +export interface JiraIssue { + id: string; + key: string; + self: string; + fields: JiraIssueFields; +} + +export interface JiraSearchResult { + issues: JiraIssue[]; + total: number; + maxResults: number; + startAt: number; + nextPageToken?: string; +} + +export interface JiraBulkFetchResult { + issues: JiraIssue[]; + errors?: Array<{ + issueIdsOrKeys: string[]; + status: number; + elementErrors?: unknown; + }>; +} + +export interface JiraAccessibleResource { + id: string; + name: string; + url: string; + scopes: string[]; + avatarUrl?: string; +} + +export interface JiraErrorResponse { + errorMessages: string[]; + errors: Record; +} + +export interface JiraAuthState { + accessToken: string; + sealedRefreshToken: string; + expiresAt: number; + cloudId: string; + siteUrl: string; + siteName: string; + email?: string; +} diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index ec42a421..35cf236e 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -29,7 +29,7 @@ export const TrackedUserSchema = z.object({ export type TrackedUser = z.infer; -export const BUILTIN_TAB_IDS = ["issues", "pullRequests", "actions", "tracked"] as const; +export const BUILTIN_TAB_IDS = ["issues", "pullRequests", "actions", "tracked", "jiraAssigned"] as const; export type BuiltinTabId = (typeof BUILTIN_TAB_IDS)[number]; export const CustomTabBaseType = z.enum(["issues", "pullRequests", "actions"]); @@ -50,6 +50,20 @@ export function isBuiltinTab(id: string): id is BuiltinTabId { return (BUILTIN_TAB_IDS as readonly string[]).includes(id); } +export const JiraAuthMethodSchema = z.enum(["oauth", "token"]).default("oauth"); + +export const JiraConfigSchema = z.object({ + enabled: z.boolean().default(false), + authMethod: JiraAuthMethodSchema, + cloudId: z.string().optional(), + siteUrl: z.string().optional(), + siteName: z.string().optional(), + email: z.string().optional(), + issueKeyDetection: z.boolean().default(true), +}); + +export type JiraConfig = z.infer; + export const ConfigSchema = z.object({ selectedOrgs: z.array(z.string()).default([]), selectedRepos: z.array(RepoRefSchema).default([]), @@ -79,6 +93,8 @@ export const ConfigSchema = z.object({ customTabs: z.array(CustomTabSchema).max(10).default([]), mcpRelayEnabled: z.boolean().default(false), mcpRelayPort: z.number().int().min(1024).max(65535).default(9876), + // Explicit defaults (NOT .default({})) — inner field defaults don't apply with .default({}) per BUG-001 + jira: JiraConfigSchema.default({ enabled: false, authMethod: "oauth", issueKeyDetection: true }), }); export type Config = z.infer; diff --git a/src/shared/types.ts b/src/shared/types.ts index 299a6af1..4cd0bcff 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -17,11 +17,14 @@ export interface RepoEntry extends RepoRef { pushedAt: string | null; } +export type IssueState = "OPEN" | "CLOSED"; +export type PullRequestState = "OPEN" | "CLOSED" | "MERGED"; + export interface Issue { id: number; number: number; title: string; - state: string; + state: IssueState; htmlUrl: string; createdAt: string; updatedAt: string; @@ -43,7 +46,7 @@ export interface PullRequest { id: number; number: number; title: string; - state: string; + state: PullRequestState; draft: boolean; htmlUrl: string; createdAt: string; diff --git a/src/shared/validation.ts b/src/shared/validation.ts index e64a0c0a..5a8d62ec 100644 --- a/src/shared/validation.ts +++ b/src/shared/validation.ts @@ -8,3 +8,12 @@ export const VALID_REPO_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/; export const VALID_TRACKED_LOGIN = /^[A-Za-z0-9-]{1,39}(\[bot\])?$/; export const SEARCH_RESULT_CAP = 1000; + +// ── Jira key detection ──────────────────────────────────────────────────────── + +const JIRA_KEY_REGEX = /\b([A-Z]{2,10}-\d+)\b/g; + +export function extractJiraKeys(text: string): string[] { + JIRA_KEY_REGEX.lastIndex = 0; + return [...new Set(Array.from(text.matchAll(JIRA_KEY_REGEX), (m) => m[1]))]; +} diff --git a/src/worker/index.ts b/src/worker/index.ts index 639eb43a..36d9e422 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/cloudflare"; -import { CryptoEnv, deriveKey, sealToken, SEAL_SALT } from "./crypto"; +import { CryptoEnv, deriveKey, sealToken, unsealTokenWithRotation, SEAL_SALT } from "./crypto"; import { SessionEnv, ensureSession } from "./session"; import { TurnstileEnv, verifyTurnstile, extractTurnstileToken } from "./turnstile"; import { validateProxyRequest, validateOrigin } from "./validation"; @@ -20,6 +20,8 @@ export interface Env extends CryptoEnv, SessionEnv, TurnstileEnv { ASSETS: { fetch: (request: Request) => Promise }; GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; + JIRA_CLIENT_ID?: string; + JIRA_CLIENT_SECRET?: string; ALLOWED_ORIGIN: string; SENTRY_DSN?: string; // e.g. "https://key@o123456.ingest.sentry.io/7890123" SENTRY_SECURITY_TOKEN?: string; // Optional: Sentry security token for Allowed Domains validation @@ -29,6 +31,7 @@ export interface Env extends CryptoEnv, SessionEnv, TurnstileEnv { type ErrorCode = | "token_exchange_failed" | "invalid_request" + | "payload_too_large" | "method_not_allowed" | "not_found" | "origin_mismatch" @@ -38,7 +41,10 @@ type ErrorCode = | "turnstile_failed" | "rate_limited" | "seal_failed" - | "internal_error"; + | "internal_error" + | "jira_token_exchange_failed" + | "jira_refresh_failed" + | "jira_proxy_error"; // Structured logging — Cloudflare auto-indexes JSON fields for querying. // NEVER log secrets: codes, tokens, client_secret, cookie values. @@ -117,10 +123,13 @@ function createIpRateLimiter(limit: number, windowMs: number): { check(ip: strin }; } -const tokenRateLimiter = createIpRateLimiter(10, 60_000); // token exchange: 10/min -const sentryRateLimiter = createIpRateLimiter(15, 60_000); // sentry tunnel: 15/min -const cspRateLimiter = createIpRateLimiter(15, 60_000); // csp report: 15/min -const proxyPreGateLimiter = createIpRateLimiter(60, 60_000); // proxy pre-gate: complements CF binding +const tokenRateLimiter = createIpRateLimiter(10, 60_000); // token exchange: 10/min +const jiraTokenRateLimiter = createIpRateLimiter(10, 60_000); // jira token exchange: 10/min +const jiraRefreshRateLimiter = createIpRateLimiter(30, 60_000); // jira token refresh: 30/min (more frequent, separate bucket) +const jiraTenantInfoLimiter = createIpRateLimiter(10, 60_000); // jira tenant info lookup: 10/min +const sentryRateLimiter = createIpRateLimiter(15, 60_000); // sentry tunnel: 15/min +const cspRateLimiter = createIpRateLimiter(15, 60_000); // csp report: 15/min +const proxyPreGateLimiter = createIpRateLimiter(60, 60_000); // proxy pre-gate: complements CF binding // CF-Connecting-IP is set by Cloudflare's proxy layer in production and by // miniflare/workerd in local dev. Always present in any real request path. @@ -196,11 +205,29 @@ function validateAndGuardProxyRoute(request: Request, env: Env, pathname: string // ── Sealed-token endpoint ──────────────────────────────────────────────────── const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token"]); - -// Module-level cache for derived seal keys, keyed by purpose. -// Invalidated on SEAL_KEY rotation via full-value fingerprint comparison. -const _sealKeyCache = new Map(); -let _sealKeyFingerprint = ""; +const ALLOWED_SEARCH_PARAMS = new Set(["jql", "maxResults", "fields", "startAt"]); +const ALLOWED_ISSUE_PARAMS = new Set(["issueIdsOrKeys", "fields"]); + +// Module-level cache for derived seal keys (used in token exchange, refresh, and proxy seal/re-seal). +// Keyed by purpose; invalidated when SEAL_KEY_NEXT changes. +const _nextKeyCache = new Map(); +let _nextKeyFingerprint = ""; + +/** Get or derive the active encryption key for the given purpose, using SEAL_KEY_NEXT if set. */ +async function getJiraEncryptKey(env: Env, purpose: string): Promise { + const activeKey = env.SEAL_KEY_NEXT ?? env.SEAL_KEY; + const fingerprint = activeKey; + if (fingerprint !== _nextKeyFingerprint) { + _nextKeyCache.clear(); + _nextKeyFingerprint = fingerprint; + } + let key = _nextKeyCache.get(purpose); + if (key === undefined) { + key = await deriveKey(activeKey, SEAL_SALT, purpose, "encrypt"); + _nextKeyCache.set(purpose, key); + } + return key; +} async function handleProxySeal(request: Request, env: Env, sessionId: string): Promise { if (request.method !== "POST") { @@ -256,17 +283,8 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P let sealed: string; try { - // Derive key with purpose-scoped info string (cached per-isolate, bounded by VALID_PURPOSES size) - const fingerprint = env.SEAL_KEY; - if (fingerprint !== _sealKeyFingerprint) { - _sealKeyCache.clear(); - _sealKeyFingerprint = fingerprint; - } - let key = _sealKeyCache.get(purpose); - if (key === undefined) { - key = await deriveKey(env.SEAL_KEY, SEAL_SALT, "aes-gcm-key:" + purpose, "encrypt"); - _sealKeyCache.set(purpose, key); - } + // Use SEAL_KEY_NEXT if set (matches token exchange/refresh behavior), falling back to SEAL_KEY. + const key = await getJiraEncryptKey(env, "aes-gcm-key:" + purpose); sealed = await sealToken(token, key); } catch (err) { // Log error server-side — do not expose crypto error details in response @@ -426,7 +444,7 @@ async function handleSentryTunnel( method: "POST", headers: sentryHeaders, body, - redirect: "error", + redirect: "manual", }); log("info", "sentry_tunnel_forwarded", { @@ -578,7 +596,7 @@ async function handleCspReport(request: Request, env: Env): Promise { ...(env.SENTRY_SECURITY_TOKEN ? { "X-Sentry-Token": env.SENTRY_SECURITY_TOKEN } : {}), }, body: JSON.stringify(payload), - redirect: "error", + redirect: "manual", }).catch(() => null) ) ); @@ -713,7 +731,7 @@ async function handleTokenExchange( client_secret: env.GITHUB_CLIENT_SECRET, code, }), - redirect: "error", + redirect: "manual", } ); githubStatus = githubResp.status; @@ -765,13 +783,581 @@ async function handleTokenExchange( }); } +// ── UUID v4 validation for cloudId (SSRF/path traversal prevention) ────────── +const CLOUD_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +// Max proxy body size: 64 KB +const JIRA_PROXY_MAX_BYTES = 64 * 1024; + +async function handleJiraTokenExchange( + request: Request, + env: Env, + cors: Record +): Promise { + if (!env.JIRA_CLIENT_ID || !env.JIRA_CLIENT_SECRET) { + return errorResponse("not_found", 404, cors); + } + + const validationResult = validateProxyRequest(request, env.ALLOWED_ORIGIN); + if (!validationResult.ok) { + return errorResponse(validationResult.code as ErrorCode, validationResult.status, cors); + } + + if (request.method !== "POST") { + return errorResponse("method_not_allowed", 405, cors); + } + + const ip = getClientIp(request); + if (!ip) return errorResponse("invalid_request", 400, cors); + if (!jiraTokenRateLimiter.check(ip)) { + log("warn", "jira_token_exchange_rate_limited", {}, request); + return new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { "Content-Type": "application/json", "Retry-After": "60", ...cors, ...SECURITY_HEADERS }, + }); + } + + const turnstileToken = extractTurnstileToken(request); + if (!turnstileToken || turnstileToken.length > 2048) { + log("warn", "jira_token_turnstile_missing", {}, request); + return errorResponse("turnstile_failed", 403, cors); + } + const turnstileResult = await verifyTurnstile(turnstileToken, ip, env, "jira-token"); + if (!turnstileResult.success) { + log("warn", "jira_token_turnstile_failed", { error_codes: turnstileResult.errorCodes }, request); + return errorResponse("turnstile_failed", 403, cors); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse("invalid_request", 400, cors); + } + + if (typeof body !== "object" || body === null) { + return errorResponse("invalid_request", 400, cors); + } + + const code = (body as Record)["code"]; + if (typeof code !== "string" || code.length === 0 || code.length > 2048) { + log("warn", "jira_token_exchange_missing_code", {}, request); + return errorResponse("invalid_request", 400, cors); + } + + // redirect_uri constructed server-side — never from client request + const redirectUri = `${env.ALLOWED_ORIGIN}/jira/callback`; + + let atlassianData: Record; + let atlassianStatus: number; + try { + const atlassianResp = await fetch("https://auth.atlassian.com/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: env.JIRA_CLIENT_ID, + client_secret: env.JIRA_CLIENT_SECRET, + code, + redirect_uri: redirectUri, + }), + redirect: "manual", + }); + atlassianStatus = atlassianResp.status; + atlassianData = (await atlassianResp.json()) as Record; + } catch (err) { + log("error", "jira_token_exchange_fetch_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + Sentry.captureException(err, { tags: { source: "worker-jira-token-exchange" } }); + return errorResponse("jira_token_exchange_failed", 400, cors); + } + + if ( + typeof atlassianData["access_token"] !== "string" || + typeof atlassianData["refresh_token"] !== "string" + ) { + log("error", "jira_token_exchange_bad_response", { + atlassian_status: atlassianStatus, + has_access_token: "access_token" in atlassianData, + has_refresh_token: "refresh_token" in atlassianData, + }, request); + return errorResponse("jira_token_exchange_failed", 400, cors); + } + + const refreshToken = atlassianData["refresh_token"] as string; + const accessToken = atlassianData["access_token"] as string; + const expiresIn = atlassianData["expires_in"] ?? 3600; + + let sealedRefreshToken: string; + try { + const key = await getJiraEncryptKey(env, "aes-gcm-key:jira-refresh-token"); + sealedRefreshToken = await sealToken(refreshToken, key); + } catch (err) { + log("error", "jira_token_seal_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + Sentry.captureException(err, { tags: { source: "worker-jira-seal" } }); + return errorResponse("seal_failed", 500, cors); + } + + log("info", "jira_token_exchange_succeeded", { atlassian_status: atlassianStatus }, request); + + return new Response(JSON.stringify({ access_token: accessToken, sealed_refresh_token: sealedRefreshToken, expires_in: expiresIn }), { + status: 200, + headers: { "Content-Type": "application/json", ...cors, ...SECURITY_HEADERS }, + }); +} + +async function handleJiraTokenRefresh( + request: Request, + env: Env, + cors: Record +): Promise { + if (!env.JIRA_CLIENT_ID || !env.JIRA_CLIENT_SECRET) { + return errorResponse("not_found", 404, cors); + } + + const validationResult = validateProxyRequest(request, env.ALLOWED_ORIGIN); + if (!validationResult.ok) { + return errorResponse(validationResult.code as ErrorCode, validationResult.status, cors); + } + + if (request.method !== "POST") { + return errorResponse("method_not_allowed", 405, cors); + } + + const ip = getClientIp(request); + if (!ip) return errorResponse("invalid_request", 400, cors); + if (!jiraRefreshRateLimiter.check(ip)) { + log("warn", "jira_token_refresh_rate_limited", {}, request); + return new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { "Content-Type": "application/json", "Retry-After": "60", ...cors, ...SECURITY_HEADERS }, + }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse("invalid_request", 400, cors); + } + + if (typeof body !== "object" || body === null) { + return errorResponse("invalid_request", 400, cors); + } + + const sealedRefreshToken = (body as Record)["sealed_refresh_token"]; + if (typeof sealedRefreshToken !== "string" || sealedRefreshToken.length === 0 || sealedRefreshToken.length > 8192) { + return errorResponse("invalid_request", 400, cors); + } + + const plainRefreshToken = await unsealTokenWithRotation( + sealedRefreshToken, + env.SEAL_KEY, + env.SEAL_KEY_NEXT, + SEAL_SALT, + "aes-gcm-key:jira-refresh-token" + ); + + if (plainRefreshToken === null) { + log("warn", "jira_token_refresh_unseal_failed", {}, request); + return errorResponse("jira_refresh_failed", 401, cors); + } + + let atlassianData: Record; + let atlassianStatus: number; + try { + const atlassianResp = await fetch("https://auth.atlassian.com/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + client_id: env.JIRA_CLIENT_ID, + client_secret: env.JIRA_CLIENT_SECRET, + refresh_token: plainRefreshToken, + }), + redirect: "manual", + }); + atlassianStatus = atlassianResp.status; + atlassianData = (await atlassianResp.json()) as Record; + } catch (err) { + log("error", "jira_token_refresh_fetch_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + Sentry.captureException(err, { tags: { source: "worker-jira-refresh" } }); + return errorResponse("jira_refresh_failed", 400, cors); + } + + if ( + typeof atlassianData["access_token"] !== "string" || + typeof atlassianData["refresh_token"] !== "string" + ) { + log("error", "jira_token_refresh_bad_response", { + atlassian_status: atlassianStatus, + }, request); + return errorResponse("jira_refresh_failed", 400, cors); + } + + const newRefreshToken = atlassianData["refresh_token"] as string; + const newAccessToken = atlassianData["access_token"] as string; + const expiresIn = atlassianData["expires_in"] ?? 3600; + + let newSealedRefreshToken: string; + try { + // Always seal with active key (SEAL_KEY_NEXT if set) for natural key rotation + const key = await getJiraEncryptKey(env, "aes-gcm-key:jira-refresh-token"); + newSealedRefreshToken = await sealToken(newRefreshToken, key); + } catch (err) { + log("error", "jira_refresh_seal_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + Sentry.captureException(err, { tags: { source: "worker-jira-refresh-seal" } }); + return errorResponse("seal_failed", 500, cors); + } + + log("info", "jira_token_refresh_succeeded", { atlassian_status: atlassianStatus }, request); + + return new Response(JSON.stringify({ access_token: newAccessToken, sealed_refresh_token: newSealedRefreshToken, expires_in: expiresIn }), { + status: 200, + headers: { "Content-Type": "application/json", ...cors, ...SECURITY_HEADERS }, + }); +} + +const ATLASSIAN_HOST_RE = /^[a-z0-9-]+\.atlassian\.net$/i; + +async function handleJiraTenantInfo( + request: Request, + env: Env, + cors: Record +): Promise { + log("info", "jira_tenant_info_entry", { cors_keys: Object.keys(cors).join(","), method: request.method }, request); + + const validationResult = validateProxyRequest(request, env.ALLOWED_ORIGIN); + if (!validationResult.ok) { + log("warn", "jira_tenant_info_validation_failed", { code: validationResult.code }, request); + return errorResponse(validationResult.code as ErrorCode, validationResult.status, cors); + } + + if (request.method !== "POST") { + return errorResponse("method_not_allowed", 405, cors); + } + + const ip = getClientIp(request); + if (!ip) return errorResponse("invalid_request", 400, cors); + if (!jiraTenantInfoLimiter.check(ip)) { + return new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { "Content-Type": "application/json", "Retry-After": "60", ...cors, ...SECURITY_HEADERS }, + }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse("invalid_request", 400, cors); + } + + const siteUrl = (body as Record)?.["siteUrl"]; + if (typeof siteUrl !== "string" || siteUrl.length === 0 || siteUrl.length > 200) { + return errorResponse("invalid_request", 400, cors); + } + + let parsed: URL; + try { + parsed = new URL(siteUrl); + } catch { + return errorResponse("invalid_request", 400, cors); + } + + if (parsed.protocol !== "https:" || !ATLASSIAN_HOST_RE.test(parsed.hostname)) { + log("warn", "jira_tenant_info_invalid_host", { hostname: parsed.hostname }, request); + return errorResponse("invalid_request", 400, cors); + } + + let resp: Response; + try { + resp = await fetch(`${parsed.origin}/_edge/tenant_info`, { + method: "GET", + headers: { "Accept": "application/json" }, + redirect: "manual", + }); + } catch (err) { + log("error", "jira_tenant_info_fetch_failed", { + error: err instanceof Error ? err.message : "unknown", + }, request); + return new Response(JSON.stringify({ error: "jira_tenant_info_failed" }), { + status: 502, + headers: { "Content-Type": "application/json", ...cors, ...SECURITY_HEADERS }, + }); + } + + log("info", "jira_tenant_info_response", { status: resp.status }, request); + + if (!resp.ok || resp.status >= 300) { + log("warn", "jira_tenant_info_upstream_error", { status: resp.status }, request); + return new Response(JSON.stringify({ error: "jira_tenant_info_failed" }), { + status: 502, + headers: { "Content-Type": "application/json", ...cors, ...SECURITY_HEADERS }, + }); + } + + let data: unknown; + try { + data = await resp.json(); + } catch { + return new Response(JSON.stringify({ error: "jira_tenant_info_failed" }), { + status: 502, + headers: { "Content-Type": "application/json", ...cors, ...SECURITY_HEADERS }, + }); + } + + const cloudId = (data as Record)?.["cloudId"]; + if (typeof cloudId !== "string" || !CLOUD_ID_RE.test(cloudId)) { + log("warn", "jira_tenant_info_invalid_cloud_id", {}, request); + return new Response(JSON.stringify({ error: "jira_tenant_info_failed" }), { + status: 502, + headers: { "Content-Type": "application/json", ...cors, ...SECURITY_HEADERS }, + }); + } + + return new Response(JSON.stringify({ cloudId }), { + status: 200, + headers: { "Content-Type": "application/json", ...cors, ...SECURITY_HEADERS }, + }); +} + +async function handleJiraProxy( + request: Request, + env: Env, + sessionId: string, + setCookie: string | undefined +): Promise { + if (!env.JIRA_CLIENT_ID) { + return errorResponse("not_found", 404); + } + + if (request.method !== "POST") { + return errorResponse("method_not_allowed", 405); + } + + // Content-Length pre-check (optimization; post-read check is authoritative) + if (!checkContentLength(request, JIRA_PROXY_MAX_BYTES)) { + log("warn", "jira_proxy_content_length_exceeded", { + content_length: request.headers.get("Content-Length"), + }, request); + return buildProxyResponse(errorResponse("payload_too_large", 413), setCookie); + } + + let bodyText: string; + try { + bodyText = await request.text(); + } catch { + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + + // Authoritative size check post-read + if (bodyText.length > JIRA_PROXY_MAX_BYTES) { + log("warn", "jira_proxy_body_too_large", { body_length: bodyText.length }, request); + return buildProxyResponse(errorResponse("payload_too_large", 413), setCookie); + } + + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch { + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + + if (typeof parsed !== "object" || parsed === null) { + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + + // Destructure only non-secret fields for logging; never log email or sealed + const { endpoint, cloudId, params } = parsed as Record; + const email = (parsed as Record)["email"]; + const sealed = (parsed as Record)["sealed"]; + + if (typeof endpoint !== "string" || (endpoint !== "search" && endpoint !== "issue")) { + log("warn", "jira_proxy_invalid_endpoint", { endpoint }, request); + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + + if (typeof cloudId !== "string" || !CLOUD_ID_RE.test(cloudId)) { + log("warn", "jira_proxy_invalid_cloud_id", { sessionId }, request); + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + + if (typeof email !== "string" || email.length === 0 || email.length > 254) { + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + + if (typeof sealed !== "string" || sealed.length === 0) { + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + + // maxResults cap for search endpoint + if (endpoint === "search") { + const maxResultsRaw = (params as Record | null | undefined)?.["maxResults"]; + const maxResults = typeof maxResultsRaw === "number" ? maxResultsRaw : Number(maxResultsRaw); + if (!Number.isFinite(maxResults) || maxResults > 100) { + log("warn", "jira_proxy_max_results_exceeded", { endpoint, sessionId }, request); + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + } + + // issueIdsOrKeys cap and per-element validation for issue/bulkfetch endpoint + if (endpoint === "issue") { + const issueIdsOrKeys = (params as Record | null | undefined)?.["issueIdsOrKeys"]; + if (Array.isArray(issueIdsOrKeys)) { + if (issueIdsOrKeys.length > 100) { + log("warn", "jira_proxy_issue_keys_exceeded", { count: issueIdsOrKeys.length, sessionId }, request); + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + if (!issueIdsOrKeys.every((k: unknown) => typeof k === "string" && k.length > 0 && k.length <= 50)) { + log("warn", "jira_proxy_issue_keys_invalid", { sessionId }, request); + return buildProxyResponse(errorResponse("invalid_request", 400), setCookie); + } + } + } + + // Unseal API token — plaintext never logged or forwarded to client + const apiToken = await unsealTokenWithRotation( + sealed, + env.SEAL_KEY, + env.SEAL_KEY_NEXT, + SEAL_SALT, + "aes-gcm-key:jira-api-token" + ); + + if (apiToken === null) { + log("warn", "jira_proxy_unseal_failed", { sessionId }, request); + return buildProxyResponse(errorResponse("jira_proxy_error", 401), setCookie); + } + + // Construct target URL server-side — cloudId validated above + const endpointPath = endpoint === "search" ? "search/jql" : "issue/bulkfetch"; + const baseUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/${endpointPath}`; + const auth = `Basic ${btoa(`${email}:${apiToken}`)}`; + + let jiraUrl: string; + let jiraInit: RequestInit; + + if (endpoint === "search") { + // GET with params as query string — only allowlisted keys forwarded + const searchParams = new URLSearchParams(); + if (params && typeof params === "object") { + for (const [k, v] of Object.entries(params as Record)) { + if (ALLOWED_SEARCH_PARAMS.has(k) && v !== undefined && v !== null) + searchParams.set(k, Array.isArray(v) ? v.join(",") : String(v)); + } + } + jiraUrl = `${baseUrl}?${searchParams.toString()}`; + jiraInit = { + method: "GET", + headers: { "Authorization": auth, "Accept": "application/json" }, + redirect: "manual", + }; + } else { + // POST with params as JSON body — only allowlisted keys forwarded + const filteredParams: Record = {}; + if (params && typeof params === "object") { + for (const [k, v] of Object.entries(params as Record)) { + if (ALLOWED_ISSUE_PARAMS.has(k)) filteredParams[k] = v; + } + } + jiraUrl = baseUrl; + jiraInit = { + method: "POST", + headers: { + "Authorization": auth, + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(filteredParams), + redirect: "manual", + }; + } + + log("info", "jira_proxy_request", { endpoint, cloudId, sessionId }, request); + + let jiraResp: Response; + try { + jiraResp = await fetch(jiraUrl, jiraInit); + } catch (err) { + log("error", "jira_proxy_fetch_failed", { + error: err instanceof Error ? err.message : "unknown", + endpoint, + }, request); + Sentry.captureException(err, { tags: { source: "worker-jira-proxy" } }); + return buildProxyResponse(errorResponse("jira_proxy_error", 502), setCookie); + } + + if (!jiraResp.ok) { + // Return generic error — never forward Jira error bodies (may contain PII or internals). + // Normalize Jira 5xx to 502 (bad gateway) so clients don't interpret upstream errors + // as worker errors. Preserve 4xx status codes (auth/permission failures). + const outStatus = jiraResp.status >= 500 ? 502 : jiraResp.status; + log("warn", "jira_proxy_jira_error", { jira_status: jiraResp.status, out_status: outStatus, endpoint, sessionId }, request); + return buildProxyResponse( + new Response(JSON.stringify({ error: "jira_proxy_error", status: jiraResp.status }), { + status: outStatus, + headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }, + }), + setCookie + ); + } + + let responseData: unknown; + try { + responseData = await jiraResp.json(); + } catch { + return buildProxyResponse(errorResponse("jira_proxy_error", 502), setCookie); + } + + // Re-seal on access for key rotation — only when SEAL_KEY_NEXT is set + let resealed: string | undefined; + if (env.SEAL_KEY_NEXT) { + try { + const nextKey = await getJiraEncryptKey(env, "aes-gcm-key:jira-api-token"); + resealed = await sealToken(apiToken, nextKey); + } catch { + // Non-fatal: skip re-seal if it fails + } + } + + const responseBody = + resealed && typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) + ? { ...(responseData as Record), resealed } + : responseData; + + log("info", "jira_proxy_success", { endpoint, jira_status: jiraResp.status, sessionId }, request); + + return buildProxyResponse( + new Response(JSON.stringify(responseBody), { + status: 200, + headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }, + }), + setCookie + ); +} + +function buildProxyResponse(response: Response, setCookie: string | undefined): Response { + if (!setCookie) return response; + const headers = new Headers(response.headers); + headers.set("Set-Cookie", setCookie); + return new Response(response.body, { status: response.status, headers }); +} + export default Sentry.withSentry( (env: Env) => getWorkerSentryOptions(env), { async fetch(request: Request, env: Env, _ctx?: ExecutionContext): Promise { const url = new URL(request.url); const origin = request.headers.get("Origin"); - const cors = buildCorsHeaders(origin, env.ALLOWED_ORIGIN, "POST", "Content-Type"); + const cors = buildCorsHeaders(origin, env.ALLOWED_ORIGIN, "POST", "Content-Type, X-Requested-With, cf-turnstile-response"); const corsMatched = Object.keys(cors).length > 0; // Log all API requests (skip static asset requests to reduce noise) @@ -790,9 +1376,16 @@ export default Sentry.withSentry( } } - // CORS preflight for the token exchange endpoint only - if (request.method === "OPTIONS" && url.pathname === "/api/oauth/token") { - log("info", "cors_preflight", { cors_matched: corsMatched }, request); + // CORS preflight for OAuth token endpoints + const CORS_PATHS = new Set([ + "/api/oauth/token", + "/api/oauth/jira/token", + "/api/oauth/jira/refresh", + "/api/jira/tenant-info", + "/api/jira/proxy", + ]); + if (request.method === "OPTIONS" && CORS_PATHS.has(url.pathname)) { + log("info", "cors_preflight", { cors_matched: corsMatched, pathname: url.pathname }, request); return new Response(null, { status: 204, headers: { ...cors, "Access-Control-Max-Age": "86400", ...SECURITY_HEADERS }, @@ -819,6 +1412,10 @@ export default Sentry.withSentry( }); } + if (url.pathname === "/api/jira/tenant-info") { + return handleJiraTenantInfo(request, env, cors); + } + // ── Proxy routes: validation, session, and rate limiting ───────────────── // Applies to /api/proxy/*, /api/jira/* // validateAndGuardProxyRoute handles OPTIONS preflight for proxy routes. @@ -891,9 +1488,21 @@ export default Sentry.withSentry( return sealResponse; } + if (url.pathname === "/api/jira/proxy") { + return handleJiraProxy(request, env, sessionId, setCookie); + } + // Other proxy routes not yet implemented — fall through to 404 } + if (url.pathname === "/api/oauth/jira/token") { + return handleJiraTokenExchange(request, env, cors); + } + + if (url.pathname === "/api/oauth/jira/refresh") { + return handleJiraTokenRefresh(request, env, cors); + } + if (url.pathname.startsWith("/api/")) { log("warn", "api_not_found", { method: request.method, diff --git a/src/worker/turnstile.ts b/src/worker/turnstile.ts index 298e7fe4..8b49cf61 100644 --- a/src/worker/turnstile.ts +++ b/src/worker/turnstile.ts @@ -11,7 +11,7 @@ interface TurnstileResponse { /** * Verifies a Turnstile challenge token by calling the Cloudflare siteverify API. * - * - Uses redirect: "error" to prevent SSRF via redirect chaining. + * - Uses redirect: "manual" to prevent SSRF via redirect chaining (workerd does not support "error"). * - Includes idempotency_key to deduplicate processing on network-timeout retries. * Note: tokens are single-use — once verified, the token is consumed. Do NOT * retry this function on failure; return 403 and require the SPA to get a new token. @@ -38,7 +38,7 @@ export async function verifyTurnstile( resp = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body, - redirect: "error", + redirect: "manual", signal: controller.signal, }); } catch (err) { @@ -58,7 +58,8 @@ export async function verifyTurnstile( } if (data.success) { - if (expectedAction !== undefined && data.action !== expectedAction) { + // Test keys don't return action in siteverify response — only check when present + if (expectedAction !== undefined && data.action !== undefined && data.action !== expectedAction) { return { success: false, errorCodes: ["action-mismatch"] }; } return { success: true }; diff --git a/tests/app/lib/mcp-relay.test.ts b/tests/app/lib/mcp-relay.test.ts index 50dc74ee..9c296191 100644 --- a/tests/app/lib/mcp-relay.test.ts +++ b/tests/app/lib/mcp-relay.test.ts @@ -158,8 +158,8 @@ describe("updateRelaySnapshot / handleRequest", () => { }); it("stores snapshot and returns PRs via GET_OPEN_PRS", () => { - const issues = [makeIssue({ state: "open" })]; - const prs = [makePullRequest({ state: "open", repoFullName: "owner/repo" })]; + const issues = [makeIssue({ state: "OPEN" })]; + const prs = [makePullRequest({ state: "OPEN", repoFullName: "owner/repo" })]; const runs = [makeWorkflowRun({ conclusion: "success" })]; mod.updateRelaySnapshot({ issues, pullRequests: prs, workflowRuns: runs, lastUpdatedAt: Date.now() }); @@ -250,14 +250,14 @@ describe("GET_DASHBOARD_SUMMARY handler", () => { it("computes correct summary counts from snapshot", () => { const issues = [ - makeIssue({ state: "open" }), - makeIssue({ state: "open" }), - makeIssue({ state: "closed" }), + makeIssue({ state: "OPEN" }), + makeIssue({ state: "OPEN" }), + makeIssue({ state: "CLOSED" }), ]; const prs = [ - makePullRequest({ state: "open", reviewDecision: "REVIEW_REQUIRED" }), - makePullRequest({ state: "open", reviewDecision: "APPROVED" }), - makePullRequest({ state: "closed" }), + makePullRequest({ state: "OPEN", reviewDecision: "REVIEW_REQUIRED" }), + makePullRequest({ state: "OPEN", reviewDecision: "APPROVED" }), + makePullRequest({ state: "CLOSED" }), ]; const runs = [ makeWorkflowRun({ conclusion: "failure" }), @@ -315,8 +315,8 @@ describe("GET_OPEN_PRS repo filter", () => { }); it("filters by repo when repo param is provided", () => { - const pr1 = makePullRequest({ state: "open", repoFullName: "owner/repo-a" }); - const pr2 = makePullRequest({ state: "open", repoFullName: "owner/repo-b" }); + const pr1 = makePullRequest({ state: "OPEN", repoFullName: "owner/repo-a" }); + const pr2 = makePullRequest({ state: "OPEN", repoFullName: "owner/repo-b" }); mod.updateRelaySnapshot({ issues: [], pullRequests: [pr1, pr2], workflowRuns: [], lastUpdatedAt: Date.now() }); const responses: string[] = []; @@ -338,9 +338,9 @@ describe("GET_OPEN_PRS repo filter", () => { it("returns all open PRs when no filter is provided", () => { const prs = [ - makePullRequest({ state: "open" }), - makePullRequest({ state: "open" }), - makePullRequest({ state: "closed" }), + makePullRequest({ state: "OPEN" }), + makePullRequest({ state: "OPEN" }), + makePullRequest({ state: "CLOSED" }), ]; mod.updateRelaySnapshot({ issues: [], pullRequests: prs, workflowRuns: [], lastUpdatedAt: Date.now() }); @@ -378,7 +378,7 @@ describe("GET_PR_DETAILS handler", () => { }); it("returns PR by repo+number", () => { - const pr = makePullRequest({ number: 42, repoFullName: "owner/repo", state: "open" }); + const pr = makePullRequest({ number: 42, repoFullName: "owner/repo", state: "OPEN" }); mod.updateRelaySnapshot({ issues: [], pullRequests: [pr], workflowRuns: [], lastUpdatedAt: Date.now() }); const responses: string[] = []; @@ -420,7 +420,7 @@ describe("GET_PR_DETAILS handler", () => { }); it("returns PR by numeric id", () => { - const pr = makePullRequest({ state: "open" }); + const pr = makePullRequest({ state: "OPEN" }); mod.updateRelaySnapshot({ issues: [], pullRequests: [pr], workflowRuns: [], lastUpdatedAt: Date.now() }); const responses: string[] = []; @@ -467,8 +467,8 @@ describe("GET_OPEN_PRS status filter", () => { it("filters by status=draft", () => { const prs = [ - makePullRequest({ state: "open", draft: true }), - makePullRequest({ state: "open", draft: false }), + makePullRequest({ state: "OPEN", draft: true }), + makePullRequest({ state: "OPEN", draft: false }), ]; const responses = setupAndConnect(prs); ws._triggerMessage(JSON.stringify({ jsonrpc: "2.0", id: 90, method: "get_open_prs", params: { status: "draft" } })); @@ -478,9 +478,9 @@ describe("GET_OPEN_PRS status filter", () => { it("filters by status=needs_review (non-draft, REVIEW_REQUIRED)", () => { const prs = [ - makePullRequest({ state: "open", draft: false, reviewDecision: "REVIEW_REQUIRED" }), - makePullRequest({ state: "open", draft: true, reviewDecision: "REVIEW_REQUIRED" }), - makePullRequest({ state: "open", draft: false, reviewDecision: "APPROVED" }), + makePullRequest({ state: "OPEN", draft: false, reviewDecision: "REVIEW_REQUIRED" }), + makePullRequest({ state: "OPEN", draft: true, reviewDecision: "REVIEW_REQUIRED" }), + makePullRequest({ state: "OPEN", draft: false, reviewDecision: "APPROVED" }), ]; const responses = setupAndConnect(prs); ws._triggerMessage(JSON.stringify({ jsonrpc: "2.0", id: 91, method: "get_open_prs", params: { status: "needs_review" } })); @@ -490,8 +490,8 @@ describe("GET_OPEN_PRS status filter", () => { it("filters by status=failing", () => { const prs = [ - makePullRequest({ state: "open", checkStatus: "failure" }), - makePullRequest({ state: "open", checkStatus: "success" }), + makePullRequest({ state: "OPEN", checkStatus: "failure" }), + makePullRequest({ state: "OPEN", checkStatus: "success" }), ]; const responses = setupAndConnect(prs); ws._triggerMessage(JSON.stringify({ jsonrpc: "2.0", id: 92, method: "get_open_prs", params: { status: "failing" } })); @@ -501,8 +501,8 @@ describe("GET_OPEN_PRS status filter", () => { it("filters by status=approved", () => { const prs = [ - makePullRequest({ state: "open", reviewDecision: "APPROVED" }), - makePullRequest({ state: "open", reviewDecision: "REVIEW_REQUIRED" }), + makePullRequest({ state: "OPEN", reviewDecision: "APPROVED" }), + makePullRequest({ state: "OPEN", reviewDecision: "REVIEW_REQUIRED" }), ]; const responses = setupAndConnect(prs); ws._triggerMessage(JSON.stringify({ jsonrpc: "2.0", id: 93, method: "get_open_prs", params: { status: "approved" } })); @@ -527,7 +527,7 @@ describe("GET_OPEN_ISSUES handler", () => { }); it("returns open issues", () => { - const issues = [makeIssue({ state: "open" }), makeIssue({ state: "open" }), makeIssue({ state: "closed" })]; + const issues = [makeIssue({ state: "OPEN" }), makeIssue({ state: "OPEN" }), makeIssue({ state: "CLOSED" })]; mod.updateRelaySnapshot({ issues, pullRequests: [], workflowRuns: [], lastUpdatedAt: Date.now() }); const responses: string[] = []; @@ -540,7 +540,7 @@ describe("GET_OPEN_ISSUES handler", () => { }); it("filters by repo", () => { - const issues = [makeIssue({ state: "open", repoFullName: "owner/a" }), makeIssue({ state: "open", repoFullName: "owner/b" })]; + const issues = [makeIssue({ state: "OPEN", repoFullName: "owner/a" }), makeIssue({ state: "OPEN", repoFullName: "owner/b" })]; mod.updateRelaySnapshot({ issues, pullRequests: [], workflowRuns: [], lastUpdatedAt: Date.now() }); const responses: string[] = []; diff --git a/tests/app/lib/proxy.test.ts b/tests/app/lib/proxy.test.ts index 0c4e0a1f..a2bdf703 100644 --- a/tests/app/lib/proxy.test.ts +++ b/tests/app/lib/proxy.test.ts @@ -252,7 +252,6 @@ describe("acquireTurnstileToken", () => { const token = await tokenPromise; expect(token).toBe("test-token-abc"); - expect(mockTurnstile.ready).toHaveBeenCalledOnce(); expect(mockTurnstile.render).toHaveBeenCalledWith( expect.any(HTMLDivElement), expect.objectContaining({ action: "seal", retry: "never" }), diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 8fa105a5..2cd7fbc1 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -31,6 +31,21 @@ vi.mock("../../src/app/stores/auth", () => ({ isAuthenticated: () => true, onAuthCleared: vi.fn((cb: () => void) => { authClearCallbacks.push(cb); }), DASHBOARD_STORAGE_KEY: "github-tracker:dashboard", + jiraAuth: vi.fn(() => null), + isJiraAuthenticated: vi.fn(() => false), + setJiraAuth: vi.fn(), + clearJiraAuth: vi.fn(), + ensureJiraTokenValid: vi.fn().mockResolvedValue(false), +})); + +vi.mock("../../src/app/services/jira-client", () => ({ + JiraClient: vi.fn(), + JiraProxyClient: vi.fn(), +})); + +vi.mock("../../src/app/services/jira-keys", () => ({ + detectAndLookupJiraKeys: vi.fn().mockResolvedValue(new Map()), + clearJiraKeyCache: vi.fn(), })); // Mock github service (used by Header + DashboardPage org sync) @@ -40,6 +55,13 @@ vi.mock("../../src/app/services/github", () => ({ getClient: () => null, })); +// Mock notifications lib +vi.mock("../../src/app/lib/notifications", () => ({ + detectNewItems: vi.fn(() => []), + dispatchNotifications: vi.fn(), + _resetNotificationState: vi.fn(), +})); + // Mock errors lib — return empty by default vi.mock("../../src/app/lib/errors", () => ({ getErrors: vi.fn().mockReturnValue([]), @@ -66,6 +88,8 @@ let capturedOnHotData: (( runUpdates: Map, generation: number, ) => void) | null = null; +// capturedOnTargetedData is populated by the createEventsPollCoordinator mock +let capturedOnTargetedData: ((data: DashboardData, affectedRepos: string[]) => void) | null = null; // DashboardPage and pollService are imported dynamically after each vi.resetModules() // so the module-level _coordinator variable is always fresh (null) per test. @@ -115,7 +139,14 @@ beforeEach(async () => { return { destroy: vi.fn() }; } ), + createEventsPollCoordinator: vi.fn().mockImplementation( + (_getUsername: unknown, _trackedRepoNames: unknown, _isFullRefreshing: unknown, onTargetedData: typeof capturedOnTargetedData) => { + capturedOnTargetedData = onTargetedData; + return { destroy: vi.fn() }; + } + ), rebuildHotSets: vi.fn(), + seedHotSetsFromTargeted: vi.fn(), clearHotSets: vi.fn(), getHotPollGeneration: vi.fn().mockReturnValue(0), })); @@ -132,6 +163,7 @@ beforeEach(async () => { mockLocationReplace.mockClear(); capturedFetchAll = null; capturedOnHotData = null; + capturedOnTargetedData = null; vi.mocked(authStore.clearAuth).mockClear(); vi.mocked(authStore.expireToken).mockClear(); vi.mocked(pollService.fetchAllData).mockResolvedValue({ @@ -582,61 +614,6 @@ describe("DashboardPage — data flow", () => { screen.getByRole("status"); }); - it("skipped fetch (notifications gate) keeps existing data", async () => { - const issues = [makeIssue({ id: 5, title: "Existing issue" })]; - // First call: returns real data; subsequent calls: skipped=true - vi.mocked(pollService.fetchAllData) - .mockResolvedValueOnce({ issues, pullRequests: [], workflowRuns: [], errors: [] }) - .mockResolvedValue({ issues: [], pullRequests: [], workflowRuns: [], errors: [], skipped: true }); - render(() => ); - await waitFor(() => { - // Repo group header visible (collapsed — verify data reached the tab) - screen.getByText("owner/repo"); - screen.getByText("1 issue"); - }); - - // Trigger a second fetch via the captured callback — skipped result should not erase data - await capturedFetchAll?.(); - // Data still present (collapsed repo group summary persists) - screen.getByText("1 issue"); - }); - - it("auto-prune runs after first non-skipped poll even if a skipped poll occurred first", async () => { - configStore.updateConfig({ - enableTracking: true, - selectedRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], - }); - viewStore.updateViewState({ - trackedItems: [{ - id: 555, - number: 55, - type: "issue" as const, - repoFullName: "org/repo", - title: "Will be pruned after non-skipped poll", - addedAt: Date.now(), - }], - }); - - // First call: skipped — hasFetchedFresh must stay false, no pruning - vi.mocked(pollService.fetchAllData) - .mockResolvedValueOnce({ issues: [], pullRequests: [], workflowRuns: [], errors: [], skipped: true }) - // Second call: real data with empty issues — item 555 absent means closed - .mockResolvedValueOnce({ issues: [], pullRequests: [], workflowRuns: [], errors: [] }); - - render(() => ); - - // After the first (skipped) fetch, tracked item must NOT be pruned yet - await waitFor(() => { - expect(viewStore.viewState.trackedItems.length).toBe(1); - }); - - // Trigger a second fetch — non-skipped, sets hasFetchedFresh=true, triggers prune - await capturedFetchAll?.(); - - await waitFor(() => { - expect(viewStore.viewState.trackedItems.length).toBe(0); - }); - }); }); describe("DashboardPage — auth error handling", () => { @@ -774,7 +751,7 @@ describe("DashboardPage — onHotData integration", () => { const testPR = makePullRequest({ id: 42, checkStatus: "pending", - state: "open", + state: "OPEN", reviewDecision: null, }); vi.mocked(pollService.fetchAllData).mockResolvedValue({ @@ -797,7 +774,7 @@ describe("DashboardPage — onHotData integration", () => { // Simulate hot poll returning a status update (generation=0 matches default mock) const prUpdates = new Map([[42, { - state: "OPEN", + state: "OPEN" as const, checkStatus: "success" as const, mergeStateStatus: "CLEAN", reviewDecision: "APPROVED" as const, @@ -815,7 +792,7 @@ describe("DashboardPage — onHotData integration", () => { const testPR = makePullRequest({ id: 43, checkStatus: "pending", - state: "open", + state: "OPEN", }); vi.mocked(pollService.fetchAllData).mockResolvedValue({ issues: [], @@ -842,7 +819,7 @@ describe("DashboardPage — onHotData integration", () => { // Send update with stale generation (999 !== mock default of 0) const prUpdates = new Map([[43, { - state: "OPEN", + state: "OPEN" as const, checkStatus: "success" as const, mergeStateStatus: "CLEAN", reviewDecision: null, @@ -899,6 +876,43 @@ describe("DashboardPage — onHotData integration", () => { // the produce() mechanism; this confirms the run path is wired. expect(screen.getByText(/1 workflow/)).toBeTruthy(); }); + + it("splices terminal (MERGED) PR from store via capturedOnHotData", async () => { + const testPR = makePullRequest({ + id: 99, + checkStatus: "pending", + state: "OPEN", + reviewDecision: null, + }); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [testPR], + workflowRuns: [], + errors: [], + }); + render(() => ); + await waitFor(() => { + expect(capturedOnHotData).not.toBeNull(); + }); + + const user = userEvent.setup(); + await user.click(screen.getByText("Pull Requests")); + await waitFor(() => { + screen.getByText("1 PR"); + }); + + const prUpdates = new Map([[99, { + state: "MERGED" as const, + checkStatus: "success" as const, + mergeStateStatus: "CLEAN", + reviewDecision: null, + }]]); + capturedOnHotData!(prUpdates, new Map(), 0); + + await waitFor(() => { + expect(screen.queryByText("1 PR")).toBeNull(); + }); + }); }); describe("DashboardPage — tracked tab", () => { @@ -921,6 +935,7 @@ describe("DashboardPage — tracked tab", () => { id: 42, number: 7, type: "issue" as const, + source: "github" as const, repoFullName: "owner/repo", title: "Tracked issue", addedAt: Date.now(), @@ -943,6 +958,7 @@ describe("DashboardPage — tracked tab", () => { id: 999, number: 99, type: "issue" as const, + source: "github" as const, repoFullName: "org/repo", title: "Will be pruned", addedAt: Date.now(), @@ -977,6 +993,7 @@ describe("DashboardPage — tracked tab", () => { id: 888, number: 88, type: "issue" as const, + source: "github" as const, repoFullName: "org/deselected-repo", title: "Should be kept", addedAt: Date.now(), @@ -1012,6 +1029,7 @@ describe("DashboardPage — tracked tab", () => { id: 777, number: 77, type: "issue" as const, + source: "github" as const, repoFullName: "org/repo", title: "Should survive cold start", addedAt: Date.now(), @@ -1041,6 +1059,7 @@ describe("DashboardPage — tracked tab", () => { id: 666, number: 66, type: "issue" as const, + source: "github" as const, repoFullName: "ext/upstream", title: "Upstream item closed", addedAt: Date.now(), @@ -1661,4 +1680,213 @@ describe("DashboardPage — tabCounts applies filterPreset", () => { expect(customTab.textContent?.replace(/\D+/g, "")).toBe("2"); }); }); + + describe("Jira auth guard", () => { + it("does not write Jira issues when auth becomes invalid during fetch", async () => { + let authenticated = true; + vi.mocked(authStore.isJiraAuthenticated).mockImplementation(() => authenticated); + vi.mocked(authStore.jiraAuth).mockReturnValue({ + cloudId: "test-cloud-id", + accessToken: "test-access-token", + sealedRefreshToken: "sealed", + expiresAt: Date.now() + 3600000, + siteUrl: "https://test.atlassian.net", + siteName: "Test Site", + }); + vi.mocked(authStore.ensureJiraTokenValid).mockResolvedValue(true); + + const jiraClientMod = await import("../../src/app/services/jira-client"); + const mockSearchJql = vi.fn().mockImplementation(async () => { + authenticated = false; + return { + issues: [{ + key: "STALE-1", id: "1", self: "https://test.atlassian.net/rest/api/3/issue/1", + fields: { + summary: "Stale issue from previous user", + status: { id: "1", name: "To Do", statusCategory: { id: 1, key: "new", name: "To Do" } }, + priority: null, assignee: null, + project: { id: "1", key: "STALE", name: "Stale Project" }, + }, + }], + total: 1, maxResults: 100, startAt: 0, + }; + }); + vi.mocked(jiraClientMod.JiraClient).mockImplementation(function () { + return { searchJql: mockSearchJql, bulkFetch: vi.fn().mockResolvedValue({ issues: [] }), getIssue: vi.fn() } as any; + } as any); + + configStore.updateJiraConfig({ enabled: true, siteUrl: "https://test.atlassian.net", siteName: "Test Site", authMethod: "oauth" }); + + render(() => ); + await new Promise((r) => setTimeout(r, 200)); + + expect(screen.queryByText("STALE-1")).toBeNull(); + expect(screen.queryByText("Stale issue from previous user")).toBeNull(); + }); + }); +}); + +describe("DashboardPage — events poll targeted merge", () => { + it("preserves tracked-user-only items from affected repos", async () => { + const trackedUserIssue = makeIssue({ id: 99, title: "Tracked user only", repoFullName: "org/repo", surfacedBy: ["other-user"] }); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [trackedUserIssue, makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo" })], + pullRequests: [], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { screen.getByText("org/repo"); }); + + const targetedData: DashboardData = { + issues: [makeIssue({ id: 1, title: "My issue updated", repoFullName: "org/repo" })], + pullRequests: [], + workflowRuns: [], + errors: [], + }; + capturedOnTargetedData?.(targetedData, ["org/repo"]); + + await waitFor(() => { + screen.getByText("2 issues"); + }); + }); + + it("merges surfacedBy annotations via union for issues", async () => { + const sharedIssue = makeIssue({ id: 50, title: "Shared", repoFullName: "org/repo", surfacedBy: ["primary", "tracked-user"] }); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [sharedIssue], + pullRequests: [], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { screen.getByText("org/repo"); }); + + const targetedIssue = makeIssue({ id: 50, title: "Shared updated", repoFullName: "org/repo", surfacedBy: ["primary"] }); + const targetedData: DashboardData = { + issues: [targetedIssue], + pullRequests: [], + workflowRuns: [], + errors: [], + }; + capturedOnTargetedData?.(targetedData, ["org/repo"]); + + await waitFor(() => { + screen.getByText("1 issue"); + }); + + // handleTargetedData mutates data items in-place before merging into the store + expect(targetedIssue.surfacedBy).toEqual(expect.arrayContaining(["primary", "tracked-user"])); + expect(targetedIssue.surfacedBy).toHaveLength(2); + }); + + it("merges surfacedBy annotations via union for pull requests", async () => { + const sharedPR = makePullRequest({ id: 60, repoFullName: "org/repo", surfacedBy: ["primary", "tracked-user"] }); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [sharedPR], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(capturedOnTargetedData).not.toBeNull()); + + const targetedPR = makePullRequest({ id: 60, repoFullName: "org/repo", surfacedBy: ["primary"] }); + const targetedData: DashboardData = { + issues: [], + pullRequests: [targetedPR], + workflowRuns: [], + errors: [], + }; + capturedOnTargetedData?.(targetedData, ["org/repo"]); + + // handleTargetedData mutates data items in-place before merging into the store + expect(targetedPR.surfacedBy).toEqual(expect.arrayContaining(["primary", "tracked-user"])); + expect(targetedPR.surfacedBy).toHaveLength(2); + }); + + it("calls detectNewItems and dispatchNotifications after targeted merge", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(capturedOnTargetedData).not.toBeNull()); + + const notifLib = await import("../../src/app/lib/notifications"); + vi.mocked(notifLib.detectNewItems).mockClear(); + vi.mocked(notifLib.dispatchNotifications).mockClear(); + + const targetedData: DashboardData = { + issues: [makeIssue({ id: 200, title: "New via events", repoFullName: "org/repo" })], + pullRequests: [], + workflowRuns: [], + errors: [], + }; + capturedOnTargetedData?.(targetedData, ["org/repo"]); + + expect(vi.mocked(notifLib.detectNewItems)).toHaveBeenCalledWith(targetedData); + expect(vi.mocked(notifLib.dispatchNotifications)).toHaveBeenCalled(); + }); + + it("calls seedHotSetsFromTargeted after targeted merge", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(capturedOnTargetedData).not.toBeNull()); + + vi.mocked(pollService.seedHotSetsFromTargeted).mockClear(); + + const targetedData: DashboardData = { + issues: [], + pullRequests: [makePullRequest({ id: 300, repoFullName: "org/repo" })], + workflowRuns: [], + errors: [], + }; + capturedOnTargetedData?.(targetedData, ["org/repo"]); + + expect(vi.mocked(pollService.seedHotSetsFromTargeted)).toHaveBeenCalledWith(targetedData); + }); + + it("does not update lastRefreshedAt after targeted merge (MCP relay exclusion)", async () => { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeIssue({ id: 1, repoFullName: "org/repo" })], + pullRequests: [], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => { screen.getByText("org/repo"); }); + + // The targeted merge callback does NOT call setDashboardData with a new + // lastRefreshedAt — it uses produce() which only modifies issues/PRs/runs. + // This means the MCP relay effect (which tracks lastRefreshedAt) won't fire. + // We verify this by checking that rebuildHotSets is NOT called (it's only + // called on full refresh, not targeted merge). + vi.mocked(pollService.rebuildHotSets).mockClear(); + + const targetedData: DashboardData = { + issues: [makeIssue({ id: 1, title: "Updated", repoFullName: "org/repo" })], + pullRequests: [], + workflowRuns: [], + errors: [], + }; + capturedOnTargetedData?.(targetedData, ["org/repo"]); + + // seedHotSetsFromTargeted is called (additive), NOT rebuildHotSets (full replacement) + expect(vi.mocked(pollService.rebuildHotSets)).not.toHaveBeenCalled(); + expect(vi.mocked(pollService.seedHotSetsFromTargeted)).toHaveBeenCalledWith(targetedData); + }); }); diff --git a/tests/components/dashboard/DashboardPage.test.tsx b/tests/components/dashboard/DashboardPage.test.tsx index cb44b82f..4bd8363d 100644 --- a/tests/components/dashboard/DashboardPage.test.tsx +++ b/tests/components/dashboard/DashboardPage.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; import { rateLimitCssClass } from "../../../src/app/lib/format"; +import type { PullRequest } from "../../../src/shared/types"; +import type { HotPRStatusUpdate } from "../../../src/app/services/api"; describe("rateLimitCssClass", () => { it("remaining: 0 gives text-error", () => { @@ -22,3 +24,110 @@ describe("rateLimitCssClass", () => { expect(rateLimitCssClass(499, 5000)).toBe("text-warning"); }); }); + +// ── PA-008: Hot poll terminal PR splice ─────────────────────────────────────── + +describe("hot poll terminal PR splice logic", () => { + function makeOpenPR(id: number): PullRequest { + return { + id, + number: id, + title: `PR ${id}`, + state: "OPEN", + draft: false, + htmlUrl: `https://github.com/owner/repo/pull/${id}`, + createdAt: "2024-01-10T08:00:00Z", + updatedAt: "2024-01-12T14:30:00Z", + userLogin: "octocat", + userAvatarUrl: "https://github.com/images/error/octocat_happy.gif", + headSha: "abc123", + headRef: "feature", + baseRef: "main", + assigneeLogins: [], + reviewerLogins: [], + repoFullName: "owner/repo", + checkStatus: null, + additions: 0, + deletions: 0, + changedFiles: 0, + comments: 0, + reviewThreads: 0, + labels: [], + reviewDecision: null, + totalReviewCount: 0, + enriched: true, + }; + } + + function simulateHotPollCallback( + state: { pullRequests: PullRequest[] }, + prUpdates: Map + ): void { + // Mirrors the onHotData callback logic in DashboardPage.tsx (without SolidJS store produce) + const terminalPrIds = new Set(); + for (const [prId, update] of prUpdates) { + if (update.state === "CLOSED" || update.state === "MERGED") { + terminalPrIds.add(prId); + } + } + for (const pr of state.pullRequests) { + const update = prUpdates.get(pr.id); + if (!update) continue; + pr.state = update.state; + pr.checkStatus = update.checkStatus; + pr.reviewDecision = update.reviewDecision; + } + if (terminalPrIds.size > 0) { + state.pullRequests = state.pullRequests.filter((pr) => !terminalPrIds.has(pr.id)); + } + } + + it("removes a MERGED PR from pullRequests when hot poll returns state:MERGED", () => { + const state = { pullRequests: [makeOpenPR(1), makeOpenPR(2)] }; + + const prUpdates = new Map([ + [1, { state: "MERGED", checkStatus: null, mergeStateStatus: "MERGED", reviewDecision: null }], + ]); + + simulateHotPollCallback(state, prUpdates); + + expect(state.pullRequests.map((p) => p.id)).toEqual([2]); + }); + + it("removes a CLOSED PR from pullRequests when hot poll returns state:CLOSED", () => { + const state = { pullRequests: [makeOpenPR(10), makeOpenPR(20)] }; + + const prUpdates = new Map([ + [10, { state: "CLOSED", checkStatus: null, mergeStateStatus: "", reviewDecision: null }], + ]); + + simulateHotPollCallback(state, prUpdates); + + expect(state.pullRequests.map((p) => p.id)).toEqual([20]); + }); + + it("keeps OPEN PRs in pullRequests after hot poll update", () => { + const state = { pullRequests: [makeOpenPR(5)] }; + + const prUpdates = new Map([ + [5, { state: "OPEN", checkStatus: "success", mergeStateStatus: "CLEAN", reviewDecision: "APPROVED" }], + ]); + + simulateHotPollCallback(state, prUpdates); + + expect(state.pullRequests).toHaveLength(1); + expect(state.pullRequests[0].id).toBe(5); + }); + + it("removes only the MERGED PR and leaves remaining PRs intact", () => { + const state = { pullRequests: [makeOpenPR(100), makeOpenPR(101), makeOpenPR(102)] }; + + const prUpdates = new Map([ + [101, { state: "MERGED", checkStatus: null, mergeStateStatus: "MERGED", reviewDecision: null }], + ]); + + simulateHotPollCallback(state, prUpdates); + + expect(state.pullRequests.map((p) => p.id)).toEqual([100, 102]); + }); +}); diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index 16d614a7..da10d622 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -840,6 +840,25 @@ describe("IssuesTab — customTabId lock mechanics", () => { }); }); +// ── PA-015: state filter — non-OPEN issues are excluded ────────────────────── + +describe("IssuesTab — state filter", () => { + it("does not render a CLOSED issue", () => { + const issues = [ + makeIssue({ id: 1, title: "Open issue", repoFullName: "owner/repo", state: "OPEN", surfacedBy: ["me"] }), + makeIssue({ id: 2, title: "Closed issue", repoFullName: "owner/repo", state: "CLOSED", surfacedBy: ["me"] }), + ]; + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + screen.getByText("Open issue"); + expect(screen.queryByText("Closed issue")).toBeNull(); + }); +}); + // ── customTabId filter preset ──────────────────────────────────────────────── describe("IssuesTab — customTabId filter preset", () => { diff --git a/tests/components/dashboard/JiraAssignedTab.test.tsx b/tests/components/dashboard/JiraAssignedTab.test.tsx new file mode 100644 index 00000000..937fb670 --- /dev/null +++ b/tests/components/dashboard/JiraAssignedTab.test.tsx @@ -0,0 +1,409 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; + +// ── Module mocks ────────────────────────────────────────────────────────────── + +let mockTrackedItems: Array<{ source: string; jiraKey?: string }> = []; +let mockJiraFilters: { scope: string; statusCategory: string; priority: string; sortField: string; sortDirection: string } = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }; + +vi.mock("../../../src/app/stores/view", () => ({ + viewState: new Proxy({} as Record, { + get(_t, key: string) { + if (key === "trackedItems") return mockTrackedItems; + if (key === "tabFilters") return { jiraAssigned: mockJiraFilters }; + if (key === "lockedRepos") return {}; + if (key === "expandedRepos") return { jiraAssigned: new Proxy({}, { get: () => true }) }; + return undefined; + }, + }), + setTabFilter: vi.fn(), + resetAllTabFilters: vi.fn(), + JiraFiltersSchema: { parse: vi.fn((_x: unknown) => ({ scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" })) }, + trackItem: vi.fn(), + untrackJiraItem: vi.fn(), + setAllExpanded: vi.fn(), +})); + +vi.mock("../../../src/app/stores/config", () => ({ + config: { enableTracking: false }, +})); + +import JiraAssignedTab, { _resetJiraTabState } from "../../../src/app/components/dashboard/JiraAssignedTab"; +import type { JiraIssue } from "../../../src/shared/jira-types"; +import { config } from "../../../src/app/stores/config"; +import { trackItem, untrackJiraItem, setAllExpanded } from "../../../src/app/stores/view"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeIssue( + key: string, + projectKey = "PROJ", + statusCategory: "new" | "indeterminate" | "done" = "indeterminate", + priority = "Medium" +): JiraIssue { + return { + id: `id-${key}`, + key, + self: `https://api.atlassian.com/ex/jira/cloud/rest/api/3/issue/${key}`, + fields: { + summary: `Summary for ${key}`, + status: { + id: "1", + name: statusCategory === "new" ? "To Do" : statusCategory === "done" ? "Done" : "In Progress", + statusCategory: { + id: statusCategory === "new" ? 2 : statusCategory === "done" ? 3 : 4, + key: statusCategory, + name: statusCategory === "new" ? "To Do" : statusCategory === "done" ? "Done" : "In Progress", + }, + }, + priority: { id: "2", name: priority }, + assignee: { accountId: "u1", displayName: "Alice" }, + project: { id: "p1", key: projectKey, name: `${projectKey} Project` }, + updated: "2026-04-24T12:00:00.000+0000", + }, + }; +} + +const SITE_URL = "https://mysite.atlassian.net"; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("JiraAssignedTab", () => { + beforeEach(() => { + mockTrackedItems = []; + mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "status", sortDirection: "asc" }; + _resetJiraTabState(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── Rendering basic issue list ──────────────────────────────────────────── + + it("renders issue key and summary for each issue", () => { + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2")]; + render(() => ); + + expect(screen.getByText("PROJ-1")).toBeTruthy(); + expect(screen.getByText("Summary for PROJ-1")).toBeTruthy(); + expect(screen.getByText("PROJ-2")).toBeTruthy(); + expect(screen.getByText("Summary for PROJ-2")).toBeTruthy(); + }); + + it("issue key links to the correct browse URL", () => { + const issues = [makeIssue("PROJ-42")]; + render(() => ); + + const links = screen.getAllByRole("link"); + const keyLink = links.find((l) => l.textContent === "PROJ-42"); + expect(keyLink).toBeTruthy(); + expect(keyLink!.getAttribute("href")).toBe(`${SITE_URL}/browse/PROJ-42`); + }); + + it("summary text element has title attribute for truncated hover", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + const summary = screen.getByText("Summary for PROJ-1"); + expect(summary.getAttribute("title")).toBe("Summary for PROJ-1"); + }); + + // ── Grouping by project ────────────────────────────────────────────────── + + it("groups issues by project key as section headers", () => { + const issues = [ + makeIssue("ALPHA-1", "ALPHA"), + makeIssue("BETA-1", "BETA"), + makeIssue("ALPHA-2", "ALPHA"), + ]; + render(() => ); + + expect(screen.getByText("ALPHA")).toBeTruthy(); + expect(screen.getByText("BETA")).toBeTruthy(); + }); + + it("renders issues under their correct project group", () => { + const issues = [ + makeIssue("ALPHA-1", "ALPHA"), + makeIssue("BETA-1", "BETA"), + ]; + render(() => ); + + expect(screen.getByText("ALPHA-1")).toBeTruthy(); + expect(screen.getByText("BETA-1")).toBeTruthy(); + }); + + // ── Status badge colors ─────────────────────────────────────────────────── + + it("renders status badge for each issue", () => { + const issues = [makeIssue("PROJ-1", "PROJ", "new")]; + render(() => ); + expect(screen.getByText("To Do")).toBeTruthy(); + }); + + it("renders In Progress status badge", () => { + const issues = [makeIssue("PROJ-1", "PROJ", "indeterminate")]; + render(() => ); + expect(screen.getByText("In Progress")).toBeTruthy(); + }); + + // ── Filter by statusCategory ───────────────────────────────────────────── + + it("shows all issues when no filters are active (default all/all)", () => { + const issues = [makeIssue("PROJ-1", "PROJ", "indeterminate")]; + render(() => ); + + expect(screen.getByText("PROJ-1")).toBeTruthy(); + }); + + it("filters out issues that do not match active statusCategory filter", () => { + mockJiraFilters = { scope: "assigned", statusCategory: "new", priority: "all", sortField: "status", sortDirection: "asc" }; + const issues = [ + makeIssue("PROJ-1", "PROJ", "new"), + makeIssue("PROJ-2", "PROJ", "indeterminate"), + ]; + render(() => ); + + expect(screen.getByText("PROJ-1")).toBeTruthy(); + expect(screen.queryByText("PROJ-2")).toBeNull(); + }); + + it("filters out issues that do not match active priority filter", () => { + mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "High", sortField: "status", sortDirection: "asc" }; + const issues = [ + makeIssue("PROJ-1", "PROJ", "indeterminate", "High"), + makeIssue("PROJ-2", "PROJ", "indeterminate", "Medium"), + ]; + render(() => ); + + expect(screen.getByText("PROJ-1")).toBeTruthy(); + expect(screen.queryByText("PROJ-2")).toBeNull(); + }); + + it("shows empty state when active filter matches nothing", () => { + mockJiraFilters = { scope: "assigned", statusCategory: "new", priority: "all", sortField: "status", sortDirection: "asc" }; + const issues = [makeIssue("PROJ-1", "PROJ", "indeterminate")]; + render(() => ); + + expect(screen.queryByText("PROJ-1")).toBeNull(); + expect(screen.getByText(/No issues match current filters/i)).toBeTruthy(); + }); + + it("shows 'No assigned Jira issues' when no filters active and list is empty", () => { + render(() => ); + expect(screen.getByText(/No assigned Jira issues/i)).toBeTruthy(); + }); + + // ── Empty state ─────────────────────────────────────────────────────────── + + it("shows loading spinner when loading=true and no issues yet", () => { + render(() => ); + // LoadingSpinner renders with label text + expect(screen.getByText(/Loading Jira issues/i)).toBeTruthy(); + }); + + it("does not show loading spinner when issues are already present", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + expect(screen.queryByText(/Loading Jira issues/i)).toBeNull(); + }); + + // ── Pagination ──────────────────────────────────────────────────────────── + + it("does not show pagination when items fit on one page (≤25)", () => { + const issues = Array.from({ length: 10 }, (_, i) => makeIssue(`PROJ-${i + 1}`)); + render(() => ); + expect(screen.queryByRole("button", { name: /next/i })).toBeNull(); + }); + + it("shows pagination controls when groups exceed page size", () => { + const issues = [ + ...Array.from({ length: 15 }, (_, i) => makeIssue(`ALPHA-${i + 1}`, "ALPHA")), + ...Array.from({ length: 15 }, (_, i) => makeIssue(`BETA-${i + 1}`, "BETA")), + ]; + render(() => ); + expect(screen.getByRole("button", { name: /next/i })).toBeTruthy(); + }); + + // ── No atl-paas.net images ──────────────────────────────────────────────── + + it("does not render any img with atl-paas.net src", () => { + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2")]; + const { container } = render(() => ( + + )); + const images = container.querySelectorAll("img"); + for (const img of images) { + expect(img.getAttribute("src") ?? "").not.toContain("atl-paas.net"); + } + }); + + // ── Priority badge ──────────────────────────────────────────────────────── + + it("shows priority badge for non-Medium priorities", () => { + const issues = [makeIssue("PROJ-1", "PROJ", "indeterminate", "High")]; + render(() => ); + expect(screen.getByText("High")).toBeTruthy(); + }); + + it("does not show priority badge for Medium priority", () => { + const issues = [makeIssue("PROJ-1", "PROJ", "indeterminate", "Medium")]; + render(() => ); + expect(screen.queryByText("Medium")).toBeNull(); + }); + + // ── Issue count ─────────────────────────────────────────────────────────── + + it("shows correct issue count in filter toolbar", () => { + const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2")]; + render(() => ); + expect(screen.getByText("2 issues")).toBeTruthy(); + }); + + it("shows '1 issue' (singular) for single issue", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + expect(screen.getByText("1 issue")).toBeTruthy(); + }); + + // ── Clear filter button ─────────────────────────────────────────────────── + + it("does not show Clear button when no filters are active (default state)", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + expect(screen.queryByRole("button", { name: /clear/i })).toBeNull(); + }); + + // ── Pin / unpin tracking (enableTracking: true) ─────────────────────────── + + describe("pin/unpin tracking with config.enableTracking: true", () => { + beforeEach(() => { + (config as { enableTracking: boolean }).enableTracking = true; + }); + + afterEach(() => { + (config as { enableTracking: boolean }).enableTracking = false; + }); + + it("renders pin button when tracking is enabled", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + expect(screen.getByRole("button", { name: /pin PROJ-1/i })).toBeTruthy(); + }); + + it("calls trackItem when pin button is clicked on an unpinned issue", () => { + const issue = makeIssue("PROJ-1"); + render(() => ); + + const pinButton = screen.getByRole("button", { name: /pin PROJ-1/i }); + pinButton.click(); + + expect(vi.mocked(trackItem)).toHaveBeenCalledOnce(); + const callArg = vi.mocked(trackItem).mock.calls[0][0]; + expect(callArg.id).toBe(parseInt(issue.id, 10)); + expect(callArg.source).toBe("jira"); + expect(callArg.jiraKey).toBe("PROJ-1"); + expect(callArg.type).toBe("jiraIssue"); + }); + + it("calls untrackJiraItem when unpinning a pinned issue", () => { + const issue = makeIssue("PROJ-1"); + // Seed viewState.trackedItems with a matching jira item so isPinned() is true + mockTrackedItems = [{ source: "jira", jiraKey: "PROJ-1" }]; + + render(() => ); + + const unpinButton = screen.getByRole("button", { name: /unpin PROJ-1/i }); + unpinButton.click(); + + expect(vi.mocked(untrackJiraItem)).toHaveBeenCalledOnce(); + expect(vi.mocked(untrackJiraItem)).toHaveBeenCalledWith("PROJ-1"); + }); + }); + + // ── Sort ordering ────────────────────────────────────────────────────────── + + it("renders issues in status SDLC order by default (To Do → Done)", () => { + const issues = [ + makeIssue("PROJ-1", "PROJ", "done"), + makeIssue("PROJ-2", "PROJ", "new"), + makeIssue("PROJ-3", "PROJ", "indeterminate"), + ]; + render(() => ); + + const items = screen.getAllByRole("listitem"); + const keys = items.map((el) => el.querySelector(".font-mono")?.textContent).filter(Boolean); + expect(keys).toEqual(["PROJ-2", "PROJ-3", "PROJ-1"]); + }); + + it("renders sort dropdown", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + const sortButtons = screen.getAllByRole("button").filter((b) => /sort by/i.test(b.getAttribute("aria-label") ?? "")); + expect(sortButtons.length).toBeGreaterThan(0); + }); + + // ── Expand / collapse ────────────────────────────────────────────────────── + + it("renders project group header with expand toggle button", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + + const toggleButton = screen.getByRole("button", { expanded: true }); + expect(toggleButton).toBeTruthy(); + expect(toggleButton.textContent).toContain("PROJ"); + }); + + it("calls setAllExpanded when project header is clicked", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + + const header = screen.getByRole("button", { expanded: true }); + header.click(); + + expect(vi.mocked(setAllExpanded)).toHaveBeenCalled(); + }); + + it("renders expand-all and collapse-all buttons", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + expect(screen.getByRole("button", { name: /expand all/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /collapse all/i })).toBeTruthy(); + }); + + // ── View density ─────────────────────────────────────────────────────────── + + it("does not show assignee name (redundant in assigned-to-me tab)", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + expect(screen.queryByText("Alice")).toBeNull(); + }); + + it("renders summary as

in comfortable mode", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + const summary = screen.getByText("Summary for PROJ-1"); + expect(summary.tagName).toBe("P"); + }); + + it("renders summary inline with key in compact mode", () => { + (config as { viewDensity: string }).viewDensity = "compact"; + const issues = [makeIssue("PROJ-1")]; + render(() => ); + const summary = screen.getByText("Summary for PROJ-1"); + expect(summary.tagName).toBe("SPAN"); + (config as { viewDensity: string }).viewDensity = "comfortable"; + }); + + // ── URL validation ───────────────────────────────────────────────────────── + + it("uses # href when siteUrl is not a safe Jira URL", () => { + const issues = [makeIssue("PROJ-1")]; + render(() => ); + + const links = screen.getAllByRole("link"); + const keyLink = links.find((l) => l.textContent === "PROJ-1"); + expect(keyLink!.getAttribute("href")).toBe("#"); + }); +}); diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index 835edce8..a469faf1 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -363,6 +363,51 @@ describe("PersonalSummaryStrip — mixed state", () => { }); }); +describe("PersonalSummaryStrip — state filter (OPEN only)", () => { + it("renders nothing when all issues and PRs are non-OPEN", () => { + const issues = [ + makeIssue({ assigneeLogins: ["me"], state: "CLOSED" }), + ]; + const prs = [ + makePullRequest({ userLogin: "me", draft: false, checkStatus: "failure", state: "MERGED" }), + makePullRequest({ + enriched: true, + reviewDecision: "REVIEW_REQUIRED", + reviewerLogins: ["me"], + userLogin: "author", + state: "CLOSED", + }), + ]; + + const { container } = renderStrip({ issues, pullRequests: prs }); + expect(container.innerHTML).toBe(""); + }); + + it("only counts OPEN items when mixed with CLOSED and MERGED", () => { + const issues = [ + makeIssue({ id: 1, assigneeLogins: ["me"], state: "OPEN" }), + makeIssue({ id: 2, assigneeLogins: ["me"], state: "CLOSED" }), + ]; + const prs = [ + makePullRequest({ id: 10, userLogin: "me", draft: false, checkStatus: "failure", state: "OPEN" }), + makePullRequest({ id: 11, userLogin: "me", draft: false, checkStatus: "failure", state: "MERGED" }), + makePullRequest({ id: 12, userLogin: "me", draft: false, checkStatus: "success", reviewDecision: "APPROVED", state: "OPEN" }), + makePullRequest({ id: 13, userLogin: "me", draft: false, checkStatus: "success", reviewDecision: "APPROVED", state: "CLOSED" }), + ]; + + renderStrip({ issues, pullRequests: prs }); + + const assignedButton = screen.getByText(/assigned/); + expect(assignedButton.textContent).toContain("1"); + + const blockedButton = screen.getByText(/blocked/); + expect(blockedButton.textContent).toContain("1"); + + const mergeButton = screen.getByText(/ready to merge/); + expect(mergeButton.textContent).toContain("1"); + }); +}); + describe("PersonalSummaryStrip — label context", () => { it("shows 'issue assigned' (singular) for 1 assigned issue", () => { const issues = [makeIssue({ assigneeLogins: ["me"] })]; diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index b51db27e..2b9e76a6 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -768,6 +768,40 @@ describe("PullRequestsTab — empty-repo state preservation", () => { }); }); +// ── PA-015: state filter — non-OPEN PRs are excluded ───────────────────────── + +describe("PullRequestsTab — state filter", () => { + it("does not render a MERGED PR", () => { + const prs = [ + makePullRequest({ id: 1, title: "Open PR", repoFullName: "owner/repo", state: "OPEN", surfacedBy: ["me"] }), + makePullRequest({ id: 2, title: "Merged PR", repoFullName: "owner/repo", state: "MERGED", surfacedBy: ["me"] }), + ]; + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + screen.getByText("Open PR"); + expect(screen.queryByText("Merged PR")).toBeNull(); + }); + + it("does not render a CLOSED PR", () => { + const prs = [ + makePullRequest({ id: 3, title: "Open PR", repoFullName: "owner/repo", state: "OPEN", surfacedBy: ["me"] }), + makePullRequest({ id: 4, title: "Closed PR", repoFullName: "owner/repo", state: "CLOSED", surfacedBy: ["me"] }), + ]; + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + screen.getByText("Open PR"); + expect(screen.queryByText("Closed PR")).toBeNull(); + }); +}); + // ── customTabId filter preset ──────────────────────────────────────────────── describe("PullRequestsTab — customTabId filter preset", () => { diff --git a/tests/components/dashboard/TrackedTab.test.tsx b/tests/components/dashboard/TrackedTab.test.tsx index b779c5fa..a2f649a8 100644 --- a/tests/components/dashboard/TrackedTab.test.tsx +++ b/tests/components/dashboard/TrackedTab.test.tsx @@ -29,7 +29,7 @@ vi.mock("../../../src/app/lib/url", () => ({ // ── Imports ─────────────────────────────────────────────────────────────────── import TrackedTab from "../../../src/app/components/dashboard/TrackedTab"; -import { viewState, resetViewState, updateViewState } from "../../../src/app/stores/view"; +import { viewState, resetViewState, updateViewState, untrackJiraItem } from "../../../src/app/stores/view"; // ── Setup ───────────────────────────────────────────────────────────────────── @@ -126,6 +126,18 @@ describe("TrackedTab — fallback row", () => { expect(viewState.trackedItems).toHaveLength(0); }); + + it("shows fallback row for a tracked PR whose live state is MERGED", () => { + const pr = makePullRequest({ id: 777, number: 777, title: "Merged PR", state: "MERGED" }); + const tracked = makeTrackedItem({ id: 777, number: 777, type: "pullRequest", title: "Merged PR" }); + updateViewState({ trackedItems: [tracked] }); + + render(() => ); + + expect(screen.getByText(/not in current data/)).toBeTruthy(); + expect(screen.getByText("Merged PR")).toBeTruthy(); + expect(screen.getByLabelText("Unpin #777 Merged PR")).toBeTruthy(); + }); }); describe("TrackedTab — move button disabled states", () => { @@ -264,3 +276,77 @@ describe("TrackedTab — no ignore action", () => { expect(screen.queryByLabelText(`Ignore #${issue.number} ${issue.title}`)).toBeNull(); }); }); + +describe("TrackedTab — Jira items", () => { + const jiraTracked = makeTrackedItem({ + id: -42, + type: "jiraIssue", + source: "jira", + jiraKey: "PROJ-42", + jiraProjectKey: "PROJ", + jiraStatus: "In Progress", + repoFullName: "mysite.atlassian.net/PROJ", + title: "Fix login flow", + htmlUrl: "https://mysite.atlassian.net/browse/PROJ-42", + number: undefined, + }); + + it("renders Jira tracked item with key and title", () => { + updateViewState({ trackedItems: [jiraTracked] }); + render(() => ); + + expect(screen.getByText("PROJ-42")).toBeTruthy(); + expect(screen.getByText("Fix login flow")).toBeTruthy(); + }); + + it("Jira item links to correct URL via htmlUrl", () => { + updateViewState({ trackedItems: [jiraTracked] }); + render(() => ); + + const link = screen.getByText("PROJ-42").closest("a"); + expect(link?.getAttribute("href")).toBe("https://mysite.atlassian.net/browse/PROJ-42"); + }); + + it("Jira item shows status label", () => { + updateViewState({ trackedItems: [jiraTracked] }); + render(() => ); + + expect(screen.getByText("In Progress")).toBeTruthy(); + }); + + it("mixed GitHub + Jira items render together", () => { + const issue = makeIssue({ id: 80, title: "GitHub Issue" }); + const githubTracked = makeTrackedItem({ id: 80, type: "issue", title: "GitHub Issue" }); + updateViewState({ trackedItems: [githubTracked, jiraTracked] }); + + render(() => ); + + expect(screen.getByText("GitHub Issue")).toBeTruthy(); + expect(screen.getByText("Fix login flow")).toBeTruthy(); + }); + + it("unpinning Jira item via untrackJiraItem removes it", () => { + updateViewState({ trackedItems: [jiraTracked] }); + render(() => ); + + untrackJiraItem("PROJ-42"); + expect(viewState.trackedItems).toHaveLength(0); + }); + + it("removing Jira item does not affect GitHub items", () => { + const githubTracked = makeTrackedItem({ id: 90, type: "issue", title: "Stays" }); + updateViewState({ trackedItems: [githubTracked, jiraTracked] }); + + untrackJiraItem("PROJ-42"); + expect(viewState.trackedItems).toHaveLength(1); + expect(viewState.trackedItems[0].source).toBe("github"); + }); + + it("Jira item does not render issue number prefix", () => { + updateViewState({ trackedItems: [jiraTracked] }); + render(() => ); + + // Should NOT show "#-42" or any number prefix — Jira items have number: undefined + expect(screen.queryByText(/#-42/)).toBeNull(); + }); +}); diff --git a/tests/components/settings/ApiUsageSection.test.tsx b/tests/components/settings/ApiUsageSection.test.tsx index 2a7e657f..5c7d3611 100644 --- a/tests/components/settings/ApiUsageSection.test.tsx +++ b/tests/components/settings/ApiUsageSection.test.tsx @@ -35,7 +35,7 @@ vi.mock("../../../src/app/services/api-usage", () => ({ lightSearch: "Light Search", heavyBackfill: "PR Backfill", forkCheck: "Fork Check", globalUserSearch: "Tracked User Search", unfilteredSearch: "Unfiltered Search", upstreamDiscovery: "Upstream Discovery", workflowRuns: "Workflow Runs", - hotPRStatus: "Hot PR Status", hotRunStatus: "Hot Run Status", notifications: "Notifications", + hotPRStatus: "Hot PR Status", hotRunStatus: "Hot Run Status", userEvents: "Events", validateUser: "Validate User", fetchOrgs: "Fetch Orgs", fetchRepos: "Fetch Repos", rateLimitCheck: "Rate Limit Check", graphql: "GraphQL (other)", rest: "REST (other)", }, @@ -46,11 +46,21 @@ vi.mock("../../../src/app/services/api-usage", () => ({ vi.mock("../../../src/app/stores/auth", () => ({ clearAuth: vi.fn(), + clearJiraAuth: vi.fn(), + setJiraAuth: vi.fn(), + jiraAuth: () => null, + isJiraAuthenticated: () => false, + ensureJiraTokenValid: vi.fn(), token: () => "fake-token", user: () => ({ login: "testuser", name: "Test User" }), onAuthCleared: vi.fn(), })); +vi.mock("@sentry/solid", () => ({ + captureException: vi.fn(), + withSentryErrorBoundary: vi.fn((c: unknown) => c), +})); + vi.mock("../../../src/app/stores/cache", () => ({ clearCache: vi.fn().mockResolvedValue(undefined), })); @@ -208,7 +218,7 @@ describe("ApiUsageSection — source label display", () => { ["workflowRuns", "Workflow Runs"], ["hotPRStatus", "Hot PR Status"], ["hotRunStatus", "Hot Run Status"], - ["notifications", "Notifications"], + ["userEvents", "Events"], ["validateUser", "Validate User"], ["fetchOrgs", "Fetch Orgs"], ["fetchRepos", "Fetch Repos"], diff --git a/tests/components/settings/JiraSection.test.tsx b/tests/components/settings/JiraSection.test.tsx new file mode 100644 index 00000000..2e9bf919 --- /dev/null +++ b/tests/components/settings/JiraSection.test.tsx @@ -0,0 +1,534 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@solidjs/testing-library"; +import { MemoryRouter, Route } from "@solidjs/router"; + +// ── localStorage mock ───────────────────────────────────────────────────────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, +}); + +// ── Module mocks ────────────────────────────────────────────────────────────── +// All mocks defined before any imports from the module under test. + +vi.mock("../../../src/app/stores/cache", () => ({ + clearCache: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../src/app/lib/errors", () => ({ + pushNotification: vi.fn(), + pushError: vi.fn(), + getErrors: vi.fn(() => []), + getNotifications: vi.fn(() => []), + getUnreadCount: vi.fn(() => 0), + markAllAsRead: vi.fn(), + dismissError: vi.fn(), +})); + +const mockClearJiraAuth = vi.fn(); +const mockSetJiraAuth = vi.fn(); +const mockIsJiraAuthenticated = vi.fn(() => false); +const mockJiraAuth = vi.fn(() => null as Record | null); + +vi.mock("../../../src/app/stores/auth", () => ({ + clearAuth: vi.fn(), + clearJiraAuth: (...args: unknown[]) => mockClearJiraAuth(...args), + setJiraAuth: (...args: unknown[]) => mockSetJiraAuth(...args), + jiraAuth: () => mockJiraAuth(), + isJiraAuthenticated: () => mockIsJiraAuthenticated(), + token: () => "fake-token", + user: () => ({ login: "testuser", name: "Test User", avatar_url: "" }), + onAuthCleared: vi.fn(), +})); + +const mockUpdateJiraConfig = vi.fn(); +const mockUpdateConfig = vi.fn(); +let mockConfig = { + selectedOrgs: [], + selectedRepos: [], + upstreamRepos: [], + monitoredRepos: [], + trackedUsers: [], + refreshInterval: 300, + hotPollInterval: 30, + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + notifications: { enabled: false, issues: true, pullRequests: true, workflowRuns: true }, + theme: "auto" as const, + viewDensity: "comfortable" as const, + itemsPerPage: 25, + defaultTab: "issues", + rememberLastTab: true, + enableTracking: false, + customTabs: [], + mcpRelayEnabled: false, + mcpRelayPort: 9876, + authMethod: "oauth" as const, + onboardingComplete: true, + jira: { enabled: false, authMethod: "oauth" as const, issueKeyDetection: true } as { enabled: boolean; authMethod: "oauth" | "token"; issueKeyDetection: boolean; cloudId?: string; siteUrl?: string; siteName?: string; email?: string }, +}; + +vi.mock("../../../src/app/stores/config", () => ({ + config: new Proxy({} as typeof mockConfig, { + get(_t, key: string) { return mockConfig[key as keyof typeof mockConfig]; }, + }), + updateConfig: (...args: unknown[]) => mockUpdateConfig(...args), + updateJiraConfig: (...args: unknown[]) => mockUpdateJiraConfig(...args), + setMonitoredRepo: vi.fn(), + CONFIG_STORAGE_KEY: "github-tracker:config", + ConfigSchema: { parse: vi.fn((x: unknown) => x) }, + THEME_OPTIONS: ["auto", "corporate"], + BUILTIN_TAB_IDS: ["issues", "pullRequests", "actions", "tracked"], + isBuiltinTab: (id: string) => ["issues", "pullRequests", "actions", "tracked"].includes(id), + CustomTabSchema: { parse: vi.fn((x: unknown) => x) }, + DARK_THEMES: new Set(["dim", "dracula", "dark", "forest"]), + resetConfig: vi.fn(), + loadConfig: vi.fn(), + getCustomTab: vi.fn(), +})); + +vi.mock("../../../src/app/stores/view", () => ({ + viewState: { lastActiveTab: "issues", tabFilters: {}, expandedRepos: {}, lockedRepos: {}, trackedItems: [], activeScopeTab: "involved" }, + updateViewState: vi.fn(), + resetViewState: vi.fn(), + ViewStateSchema: { parse: vi.fn((x: unknown) => x) }, +})); + +vi.mock("../../../src/app/services/api", () => ({ + fetchOrgs: vi.fn(() => Promise.resolve([])), + getClient: vi.fn(() => null), +})); + +vi.mock("../../../src/app/services/github", () => ({ + getClient: vi.fn(() => null), + getGraphqlRateLimit: vi.fn(() => null), + fetchRateLimitDetails: vi.fn(() => Promise.resolve(null)), +})); + +vi.mock("../../../src/app/services/api-usage", () => ({ + getUsageSnapshot: vi.fn(() => []), + getUsageResetAt: vi.fn(() => null), + resetUsageData: vi.fn(), + checkAndResetIfExpired: vi.fn(), + trackApiCall: vi.fn(), + updateResetAt: vi.fn(), + SOURCE_LABELS: {}, +})); + +vi.mock("../../../src/app/lib/mcp-relay", () => ({ + getRelayStatus: vi.fn(() => "disconnected"), +})); + +vi.mock("../../../src/app/lib/url", () => ({ + isSafeGitHubUrl: vi.fn(() => true), + openGitHubUrl: vi.fn(), +})); + +const mockBuildJiraAuthorizeUrl = vi.fn(() => "https://auth.atlassian.com/authorize?mock=1"); + +vi.mock("../../../src/app/lib/oauth", () => ({ + buildJiraAuthorizeUrl: () => mockBuildJiraAuthorizeUrl(), + buildOrgAccessUrl: vi.fn(() => "https://github.com/settings/connections/applications/test"), + buildAuthorizeUrl: vi.fn(() => "https://github.com/login/oauth/authorize?mock"), + generateOAuthState: vi.fn(() => "mock-state"), + JIRA_OAUTH_STATE_KEY: "github-tracker:jira-oauth-state", + OAUTH_STATE_KEY: "github-tracker:oauth-state", + OAUTH_RETURN_TO_KEY: "github-tracker:oauth-return-to", +})); + +const mockSealApiToken = vi.fn(); + +vi.mock("../../../src/app/lib/proxy", () => ({ + sealApiToken: (...args: unknown[]) => mockSealApiToken(...args), + proxyFetch: vi.fn(), +})); + +vi.mock("../../../src/app/services/jira-keys", () => ({ + clearJiraKeyCache: vi.fn(), +})); + +// Component imports after all mocks +import SettingsPage from "../../../src/app/components/settings/SettingsPage"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function renderSettings() { + return render(() => ( + + + + )); +} + +function setEnv(key: string, value: string | undefined) { + if (value === undefined) { + vi.stubEnv(key, undefined as unknown as string); + } else { + vi.stubEnv(key, value); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── +// TODO: Fix SettingsPage mock setup — too many unmocked dependencies cause render timeouts + +describe("SettingsPage Jira section — section visibility", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsJiraAuthenticated.mockReturnValue(false); + mockJiraAuth.mockReturnValue(null); + mockConfig = { ...mockConfig, jira: { enabled: false, authMethod: "oauth", issueKeyDetection: true } }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("Jira section is visible even when VITE_JIRA_CLIENT_ID is absent", async () => { + setEnv("VITE_JIRA_CLIENT_ID", undefined); + renderSettings(); + await waitFor(() => { + expect(screen.getByText("Jira Cloud Integration")).toBeTruthy(); + }); + }); + + it("OAuth button is hidden when VITE_JIRA_CLIENT_ID is absent", async () => { + setEnv("VITE_JIRA_CLIENT_ID", undefined); + renderSettings(); + await waitFor(() => { + expect(screen.queryByText(/Connect with Jira OAuth/i)).toBeNull(); + expect(screen.getByText(/Use API token/i)).toBeTruthy(); + }); + }); + + it("OAuth button is hidden when VITE_JIRA_CLIENT_ID is empty string", async () => { + setEnv("VITE_JIRA_CLIENT_ID", ""); + renderSettings(); + await waitFor(() => { + expect(screen.queryByText(/Connect with Jira OAuth/i)).toBeNull(); + expect(screen.getByText(/Use API token/i)).toBeTruthy(); + }); + }); + + it("both OAuth and API token buttons visible when VITE_JIRA_CLIENT_ID is valid", async () => { + setEnv("VITE_JIRA_CLIENT_ID", "valid-client-id-123"); + renderSettings(); + await waitFor(() => { + expect(screen.getByText("Jira Cloud Integration")).toBeTruthy(); + expect(screen.getByText(/Connect with Jira OAuth/i)).toBeTruthy(); + expect(screen.getByText(/Use API token/i)).toBeTruthy(); + }); + }); +}); + +describe("SettingsPage Jira section — disconnected state", () => { + beforeEach(() => { + vi.clearAllMocks(); + setEnv("VITE_JIRA_CLIENT_ID", "valid-client-id"); + mockIsJiraAuthenticated.mockReturnValue(false); + mockJiraAuth.mockReturnValue(null); + mockConfig = { ...mockConfig, jira: { enabled: false, authMethod: "oauth", issueKeyDetection: true } }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("shows Connect with Jira OAuth button when disconnected", async () => { + renderSettings(); + await waitFor(() => { + expect(screen.getByText(/Connect with Jira OAuth/i)).toBeTruthy(); + }); + }); + + it("shows Use API token button when disconnected", async () => { + renderSettings(); + await waitFor(() => { + expect(screen.getByText(/Use API token/i)).toBeTruthy(); + }); + }); + + it("OAuth connect button sets window.location.href to authorize URL", async () => { + const assignMock = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, assign: assignMock }, + }); + + // Track href assignment via defineProperty + let capturedHref = ""; + const locationStub = { + replace: vi.fn(), + assign: vi.fn(), + get href() { return capturedHref; }, + set href(val: string) { capturedHref = val; }, + }; + vi.stubGlobal("location", locationStub); + + mockBuildJiraAuthorizeUrl.mockReturnValue("https://auth.atlassian.com/authorize?client_id=test"); + + renderSettings(); + await waitFor(() => { + expect(screen.getByText(/Connect with Jira OAuth/i)).toBeTruthy(); + }); + + fireEvent.click(screen.getByText(/Connect with Jira OAuth/i)); + + expect(mockBuildJiraAuthorizeUrl).toHaveBeenCalled(); + expect(capturedHref).toBe("https://auth.atlassian.com/authorize?client_id=test"); + }); + + it("API token form appears when Use API token is clicked", async () => { + renderSettings(); + await waitFor(() => { + expect(screen.getByText(/Use API token/i)).toBeTruthy(); + }); + + fireEvent.click(screen.getByText(/Use API token/i)); + + await waitFor(() => { + expect(screen.getByLabelText(/Atlassian account email/i)).toBeTruthy(); + expect(screen.getByLabelText(/Atlassian API token/i)).toBeTruthy(); + expect(screen.getByLabelText(/Jira site name/i)).toBeTruthy(); + }); + }); + + it("API token connect auto-discovers Cloud ID and sets Jira auth on success", async () => { + mockSealApiToken.mockResolvedValue("sealed-blob-xyz"); + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cloudId: "a1b2c3d4-1234-4abc-89ef-a1b2c3d4e5f6" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ issues: [], total: 0, maxResults: 1, startAt: 0 }), + }); + vi.stubGlobal("fetch", mockFetch); + + renderSettings(); + await waitFor(() => expect(screen.getByText(/Use API token/i)).toBeTruthy()); + + fireEvent.click(screen.getByText(/Use API token/i)); + await waitFor(() => expect(screen.getByLabelText(/Atlassian account email/i)).toBeTruthy()); + + fireEvent.input(screen.getByLabelText(/Atlassian account email/i), { + target: { value: "user@example.com" }, + }); + fireEvent.input(screen.getByLabelText(/Atlassian API token/i), { + target: { value: "my-api-token-123" }, + }); + fireEvent.input(screen.getByLabelText(/Jira site name/i), { + target: { value: "https://mysite.atlassian.net" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /^Connect$/i })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/jira/tenant-info", expect.objectContaining({ + method: "POST", + })); + expect(mockSealApiToken).toHaveBeenCalledWith("my-api-token-123", "jira-api-token"); + expect(mockSetJiraAuth).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "sealed-blob-xyz", + sealedRefreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + cloudId: "a1b2c3d4-1234-4abc-89ef-a1b2c3d4e5f6", + email: "user@example.com", + }) + ); + expect(mockUpdateJiraConfig).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true, authMethod: "token" }) + ); + }); + }); + + it("API token connect shows error when fields are empty", async () => { + renderSettings(); + await waitFor(() => expect(screen.getByText(/Use API token/i)).toBeTruthy()); + + fireEvent.click(screen.getByText(/Use API token/i)); + await waitFor(() => expect(screen.getByRole("button", { name: /^Connect$/i })).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /^Connect$/i })); + + await waitFor(() => { + expect(screen.getByText(/Email, API token, and site name are all required/i)).toBeTruthy(); + }); + expect(mockSealApiToken).not.toHaveBeenCalled(); + }); + + it("API token connect shows error when tenant-info lookup fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: false, + status: 502, + json: async () => ({ error: "jira_tenant_info_failed" }), + })); + + renderSettings(); + await waitFor(() => expect(screen.getByText(/Use API token/i)).toBeTruthy()); + + fireEvent.click(screen.getByText(/Use API token/i)); + await waitFor(() => expect(screen.getByLabelText(/Atlassian account email/i)).toBeTruthy()); + + fireEvent.input(screen.getByLabelText(/Atlassian account email/i), { target: { value: "u@e.com" } }); + fireEvent.input(screen.getByLabelText(/Atlassian API token/i), { target: { value: "tok" } }); + fireEvent.input(screen.getByLabelText(/Jira site name/i), { target: { value: "mysite" } }); + fireEvent.click(screen.getByRole("button", { name: /^Connect$/i })); + + await waitFor(() => { + expect(screen.getByText(/Could not look up your Jira site/i)).toBeTruthy(); + }); + expect(mockSealApiToken).not.toHaveBeenCalled(); + }); + + it("API token connect shows error when proxy returns non-ok response", async () => { + mockSealApiToken.mockResolvedValue("sealed-blob"); + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cloudId: "a1b2c3d4-1234-4abc-89ef-a1b2c3d4e5f6" }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: "unauthorized" }), + }); + vi.stubGlobal("fetch", mockFetch); + + renderSettings(); + await waitFor(() => expect(screen.getByText(/Use API token/i)).toBeTruthy()); + + fireEvent.click(screen.getByText(/Use API token/i)); + await waitFor(() => expect(screen.getByLabelText(/Atlassian account email/i)).toBeTruthy()); + + fireEvent.input(screen.getByLabelText(/Atlassian account email/i), { target: { value: "u@e.com" } }); + fireEvent.input(screen.getByLabelText(/Atlassian API token/i), { target: { value: "tok" } }); + fireEvent.input(screen.getByLabelText(/Jira site name/i), { target: { value: "mysite" } }); + fireEvent.click(screen.getByRole("button", { name: /^Connect$/i })); + + await waitFor(() => { + expect(screen.getByText(/Could not connect/i)).toBeTruthy(); + }); + expect(mockSetJiraAuth).not.toHaveBeenCalled(); + }); +}); + +describe("SettingsPage Jira section — connected state", () => { + const connectedAuth = { + accessToken: "atl-access-tok", + sealedRefreshToken: "sealed-blob", + expiresAt: Date.now() + 3600_000, + cloudId: "cloud-abc", + siteUrl: "https://mysite.atlassian.net", + siteName: "My Jira Site", + }; + + beforeEach(() => { + vi.clearAllMocks(); + setEnv("VITE_JIRA_CLIENT_ID", "valid-client-id"); + mockIsJiraAuthenticated.mockReturnValue(true); + mockJiraAuth.mockReturnValue(connectedAuth); + mockConfig = { + ...mockConfig, + jira: { enabled: true, authMethod: "oauth" as const, issueKeyDetection: true, siteUrl: "https://mysite.atlassian.net", siteName: "My Jira Site" }, + }; + setEnv("VITE_JIRA_CLIENT_ID", "valid-client-id"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("shows site name when connected", async () => { + renderSettings(); + await waitFor(() => { + expect(screen.getByText("My Jira Site")).toBeTruthy(); + }); + }); + + it("shows auth method label as OAuth when authMethod=oauth", async () => { + renderSettings(); + await waitFor(() => { + // Multiple "OAuth" text nodes exist (button label + auth method label) + // Verify the auth method setting row shows "OAuth" as the value + const oauthSpans = screen.getAllByText("OAuth"); + expect(oauthSpans.length).toBeGreaterThan(0); + }); + }); + + it("shows auth method label as API Token when authMethod=token", async () => { + mockConfig = { ...mockConfig, jira: { ...mockConfig.jira!, authMethod: "token" as const } }; + renderSettings(); + await waitFor(() => { + expect(screen.getByText("API Token")).toBeTruthy(); + }); + }); + + it("shows issue key detection toggle when connected", async () => { + renderSettings(); + await waitFor(() => { + expect(screen.getByLabelText(/Issue key detection/i)).toBeTruthy(); + }); + }); + + it("issue key detection toggle calls updateJiraConfig on change", async () => { + renderSettings(); + await waitFor(() => { + expect(screen.getByLabelText(/Issue key detection/i)).toBeTruthy(); + }); + + fireEvent.change(screen.getByLabelText(/Issue key detection/i), { + target: { checked: false }, + }); + + await waitFor(() => { + expect(mockUpdateJiraConfig).toHaveBeenCalledWith({ issueKeyDetection: false }); + }); + }); + + it("shows Disconnect button when connected", async () => { + renderSettings(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /Disconnect/i })).toBeTruthy(); + }); + }); + + it("Disconnect button calls clearJiraAuth", async () => { + renderSettings(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /Disconnect/i })).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("button", { name: /Disconnect/i })); + + expect(mockClearJiraAuth).toHaveBeenCalled(); + }); + + it("Disconnect does not show OAuth connect buttons (only when disconnected)", async () => { + renderSettings(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /Disconnect/i })).toBeTruthy(); + }); + + expect(screen.queryByText(/Connect with Jira OAuth/i)).toBeNull(); + expect(screen.queryByText(/Use API token/i)).toBeNull(); + }); +}); diff --git a/tests/components/settings/SettingsPage.test.tsx b/tests/components/settings/SettingsPage.test.tsx index 16fb2af2..c8c85651 100644 --- a/tests/components/settings/SettingsPage.test.tsx +++ b/tests/components/settings/SettingsPage.test.tsx @@ -24,11 +24,21 @@ Object.defineProperty(globalThis, "localStorage", { vi.mock("../../../src/app/stores/auth", () => ({ clearAuth: vi.fn(), + clearJiraAuth: vi.fn(), + setJiraAuth: vi.fn(), + jiraAuth: () => null, + isJiraAuthenticated: () => false, + ensureJiraTokenValid: vi.fn(), token: () => "fake-token", user: () => ({ login: "testuser", name: "Test User" }), onAuthCleared: vi.fn(), })); +vi.mock("@sentry/solid", () => ({ + captureException: vi.fn(), + withSentryErrorBoundary: vi.fn((c: unknown) => c), +})); + vi.mock("../../../src/app/stores/cache", () => ({ clearCache: vi.fn().mockResolvedValue(undefined), })); diff --git a/tests/components/shared/JiraBadge.test.tsx b/tests/components/shared/JiraBadge.test.tsx new file mode 100644 index 00000000..c49af394 --- /dev/null +++ b/tests/components/shared/JiraBadge.test.tsx @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import JiraBadge from "../../../src/app/components/shared/JiraBadge"; +import type { JiraIssue, JiraStatusCategory } from "../../../src/shared/jira-types"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeIssue(statusKey: "new" | "indeterminate" | "done" = "indeterminate"): JiraIssue { + return { + id: "10001", + key: "PROJ-42", + self: "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/issue/PROJ-42", + fields: { + summary: "Fix the bug", + status: { + id: "3", + name: statusKey === "new" ? "To Do" : statusKey === "done" ? "Done" : "In Progress", + statusCategory: { + id: statusKey === "new" ? 2 : statusKey === "done" ? 3 : 4, + key: statusKey, + name: statusKey === "new" ? "To Do" : statusKey === "done" ? "Done" : "In Progress", + }, + }, + priority: { id: "2", name: "Medium" }, + assignee: null, + project: { id: "10000", key: "PROJ", name: "My Project" }, + }, + }; +} + +const SITE_URL = "https://mysite.atlassian.net"; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("JiraBadge", () => { + it("renders nothing when issue is undefined (key not detected or not yet fetched)", () => { + const { container } = render(() => ( + + )); + // Should produce no visible output + expect(container.textContent).toBe(""); + expect(container.querySelector("a")).toBeNull(); + expect(container.querySelector("span")).toBeNull(); + }); + + it("renders plain badge (no link) when issue is null (key detected but not found/accessible)", () => { + render(() => ( + + )); + const badge = screen.getByText("PROJ-42"); + expect(badge.tagName.toLowerCase()).toBe("span"); + expect(badge.closest("a")).toBeNull(); + }); + + it("renders linked badge when issue is a JiraIssue", () => { + const issue = makeIssue("indeterminate"); + render(() => ( + + )); + const link = screen.getByRole("link"); + expect(link.textContent).toBe("PROJ-42"); + expect(link.getAttribute("href")).toBe(`${SITE_URL}/browse/PROJ-42`); + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toContain("noopener"); + }); + + it("link URL uses the correct siteUrl and issue key", () => { + const issue = makeIssue("new"); + render(() => ( + + )); + const link = screen.getByRole("link"); + expect(link.getAttribute("href")).toBe("https://other.atlassian.net/browse/ABC-7"); + }); + + it("linked badge renders key text and status class", () => { + const issue = makeIssue("indeterminate"); + render(() => ( + + )); + const link = screen.getByRole("link"); + expect(link.textContent).toBe("PROJ-42"); + expect(link.className).toContain("badge-warning"); + }); + + it("applies status color class for 'new' (To Do) status category", () => { + const issue = makeIssue("new"); + render(() => ( + + )); + const link = screen.getByRole("link"); + // jiraStatusCategoryClass("new") returns a badge class — just verify it has a class + expect(link.className).toBeTruthy(); + expect(link.className).toContain("badge"); + }); + + it("applies status color class for 'indeterminate' (In Progress) status category", () => { + const issue = makeIssue("indeterminate"); + render(() => ( + + )); + const link = screen.getByRole("link"); + expect(link.className).toContain("badge"); + }); + + it("does NOT render any img with atl-paas.net src (no avatar images leaked)", () => { + const issue = makeIssue(); + const { container } = render(() => ( + + )); + const images = container.querySelectorAll("img"); + for (const img of images) { + expect(img.getAttribute("src") ?? "").not.toContain("atl-paas.net"); + } + }); + + it("renders badge-success class for done status category", () => { + const issue = makeIssue("done"); + render(() => ( + + )); + const link = screen.getByRole("link"); + expect(link.className).toContain("badge-success"); + }); + + it("renders badge-ghost class for unknown status category", () => { + // Build an issue with a statusCategory key that doesn't match any known case + const issue: JiraIssue = { + id: "10001", + key: "PROJ-42", + self: "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/issue/PROJ-42", + fields: { + summary: "Fix the bug", + status: { + id: "99", + name: "Custom Status", + statusCategory: { + id: 99, + key: "other" as JiraStatusCategory, + name: "Other", + }, + }, + priority: { id: "2", name: "Medium" }, + assignee: null, + project: { id: "10000", key: "PROJ", name: "My Project" }, + }, + }; + render(() => ( + + )); + const link = screen.getByRole("link"); + expect(link.className).toContain("badge-ghost"); + }); +}); diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index 2a30e2fd..f21723f1 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -8,7 +8,7 @@ export function makeIssue(overrides: Partial = {}): Issue { id: nextId++, number: 1, title: "Test issue", - state: "open", + state: "OPEN", htmlUrl: "https://github.com/owner/repo/issues/1", createdAt: "2024-01-10T08:00:00Z", updatedAt: "2024-01-12T14:30:00Z", @@ -27,7 +27,7 @@ export function makePullRequest(overrides: Partial = {}): PullReque id: nextId++, number: 1, title: "Test pull request", - state: "open", + state: "OPEN", draft: false, htmlUrl: "https://github.com/owner/repo/pull/1", createdAt: "2024-01-10T08:00:00Z", @@ -85,6 +85,7 @@ export function makeTrackedItem(overrides: Partial = {}): TrackedIt id, number: id, type: "issue", + source: "github", repoFullName: "owner/repo", title: "Test tracked item", addedAt: Date.now(), diff --git a/tests/lib/notifications.test.ts b/tests/lib/notifications.test.ts index f2fe9ada..c95f5353 100644 --- a/tests/lib/notifications.test.ts +++ b/tests/lib/notifications.test.ts @@ -23,7 +23,7 @@ function makeIssue(id: number): Issue { id, number: id, title: `Issue ${id}`, - state: "open", + state: "OPEN", htmlUrl: `https://github.com/owner/repo/issues/${id}`, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", @@ -41,7 +41,7 @@ function makePr(id: number): PullRequest { id, number: id, title: `PR ${id}`, - state: "open", + state: "OPEN", draft: false, htmlUrl: `https://github.com/owner/repo/pull/${id}`, createdAt: "2024-01-01T00:00:00Z", diff --git a/tests/lib/oauth.test.ts b/tests/lib/oauth.test.ts index 222d2506..22397356 100644 --- a/tests/lib/oauth.test.ts +++ b/tests/lib/oauth.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { generateOAuthState, buildAuthorizeUrl, + buildJiraAuthorizeUrl, buildOrgAccessUrl, sanitizeReturnTo, OAUTH_STATE_KEY, OAUTH_RETURN_TO_KEY, + JIRA_OAUTH_STATE_KEY, } from "../../src/app/lib/oauth"; describe("oauth helpers", () => { @@ -79,9 +81,9 @@ describe("oauth helpers", () => { expect(url.searchParams.get("scope")).toBeTruthy(); }); - it("scope value is 'repo read:org notifications'", () => { + it("scope value is 'repo read:org'", () => { const url = new URL(buildAuthorizeUrl()); - expect(url.searchParams.get("scope")).toBe("repo read:org notifications"); + expect(url.searchParams.get("scope")).toBe("repo read:org"); }); it("URL contains state param matching sessionStorage", () => { @@ -148,4 +150,58 @@ describe("oauth helpers", () => { expect(sanitizeReturnTo("")).toBe("/"); }); }); + + describe("buildJiraAuthorizeUrl", () => { + beforeEach(() => { + vi.stubEnv("VITE_JIRA_CLIENT_ID", "jira-test-client-id"); + }); + + it("throws for missing client ID", () => { + vi.stubEnv("VITE_JIRA_CLIENT_ID", ""); + expect(() => buildJiraAuthorizeUrl()).toThrow("Invalid or missing VITE_JIRA_CLIENT_ID"); + }); + + it("throws for client ID with special characters", () => { + vi.stubEnv("VITE_JIRA_CLIENT_ID", "../../../evil"); + expect(() => buildJiraAuthorizeUrl()).toThrow("Invalid or missing VITE_JIRA_CLIENT_ID"); + }); + + it("stores state in sessionStorage under JIRA_OAUTH_STATE_KEY", () => { + buildJiraAuthorizeUrl(); + expect(sessionStorage.getItem(JIRA_OAUTH_STATE_KEY)).toBeTruthy(); + }); + + it("URL state param matches sessionStorage value", () => { + const url = new URL(buildJiraAuthorizeUrl()); + const urlState = url.searchParams.get("state"); + const storedState = sessionStorage.getItem(JIRA_OAUTH_STATE_KEY); + expect(urlState).toBeTruthy(); + expect(urlState).toBe(storedState); + }); + + it("URL contains correct client_id param", () => { + const url = new URL(buildJiraAuthorizeUrl()); + expect(url.searchParams.get("client_id")).toBe("jira-test-client-id"); + }); + + it("URL contains redirect_uri pointing to /jira/callback", () => { + const url = new URL(buildJiraAuthorizeUrl()); + expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/jira/callback"); + }); + + it("URL contains required scope", () => { + const url = new URL(buildJiraAuthorizeUrl()); + expect(url.searchParams.get("scope")).toBe("read:jira-work read:jira-user offline_access"); + }); + + it("URL points to Atlassian authorize endpoint", () => { + const url = buildJiraAuthorizeUrl(); + expect(url).toContain("https://auth.atlassian.com/authorize"); + }); + + it("URL contains response_type=code", () => { + const url = new URL(buildJiraAuthorizeUrl()); + expect(url.searchParams.get("response_type")).toBe("code"); + }); + }); }); diff --git a/tests/lib/url.test.ts b/tests/lib/url.test.ts index fd6a1f9b..80c483e2 100644 --- a/tests/lib/url.test.ts +++ b/tests/lib/url.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { isSafeGitHubUrl, openGitHubUrl } from "../../src/app/lib/url"; +import { isSafeGitHubUrl, isSafeJiraSiteUrl, openGitHubUrl } from "../../src/app/lib/url"; describe("isSafeGitHubUrl", () => { it("returns true for a root GitHub URL", () => { @@ -43,6 +43,40 @@ describe("isSafeGitHubUrl", () => { }); }); +describe("isSafeJiraSiteUrl", () => { + it("returns true for a valid atlassian.net site URL", () => { + expect(isSafeJiraSiteUrl("https://mysite.atlassian.net")).toBe(true); + }); + + it("returns true for a valid atlassian.net URL with a path", () => { + expect(isSafeJiraSiteUrl("https://mysite.atlassian.net/browse/PROJ-1")).toBe(true); + }); + + it("returns false for http (non-HTTPS)", () => { + expect(isSafeJiraSiteUrl("http://mysite.atlassian.net")).toBe(false); + }); + + it("returns false for bare atlassian.net (no subdomain)", () => { + expect(isSafeJiraSiteUrl("https://atlassian.net")).toBe(false); + }); + + it("returns false for a non-atlassian.net domain", () => { + expect(isSafeJiraSiteUrl("https://evil.com")).toBe(false); + }); + + it("returns false for a multi-level subdomain (evil.foo.atlassian.net)", () => { + expect(isSafeJiraSiteUrl("https://evil.foo.atlassian.net")).toBe(false); + }); + + it("returns false for a javascript: URL", () => { + expect(isSafeJiraSiteUrl("javascript:alert(1)")).toBe(false); + }); + + it("returns false for an empty string", () => { + expect(isSafeJiraSiteUrl("")).toBe(false); + }); +}); + describe("openGitHubUrl", () => { beforeEach(() => { vi.spyOn(window, "open").mockReturnValue(null); diff --git a/tests/pages/JiraCallback.test.tsx b/tests/pages/JiraCallback.test.tsx new file mode 100644 index 00000000..1576e5f2 --- /dev/null +++ b/tests/pages/JiraCallback.test.tsx @@ -0,0 +1,440 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@solidjs/testing-library"; +import { MemoryRouter, Route } from "@solidjs/router"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("../../src/app/stores/auth", () => ({ + setJiraAuth: vi.fn(), +})); + +vi.mock("../../src/app/stores/config", () => ({ + updateJiraConfig: vi.fn(), + config: { jira: { enabled: false, authMethod: "oauth", issueKeyDetection: true } }, +})); + +vi.mock("../../src/app/lib/proxy", () => ({ + acquireTurnstileToken: vi.fn().mockResolvedValue("mock-turnstile-token"), +})); + +vi.mock("../../src/app/services/jira-client", () => ({ + JiraClient: { + getAccessibleResources: vi.fn(), + }, +})); + +import * as authStore from "../../src/app/stores/auth"; +import * as configStore from "../../src/app/stores/config"; +import * as proxyLib from "../../src/app/lib/proxy"; +import { JiraClient } from "../../src/app/services/jira-client"; +import { JIRA_OAUTH_STATE_KEY } from "../../src/app/lib/oauth"; +import JiraCallback from "../../src/app/pages/JiraCallback"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function renderCallback() { + return render(() => ( + + + + )); +} + +function setWindowSearch(params: Record) { + const search = "?" + new URLSearchParams(params).toString(); + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: { + href: `http://localhost/jira/callback${search}`, + search, + origin: "http://localhost", + pathname: "/jira/callback", + hash: "", + hostname: "localhost", + port: "", + protocol: "http:", + host: "localhost", + assign: vi.fn(), + replace: vi.fn(), + reload: vi.fn(), + }, + }); +} + +function setupValidState(state = "valid-jira-state") { + sessionStorage.setItem(JIRA_OAUTH_STATE_KEY, state); +} + +function makeResource(id = "cloud-abc", name = "My Site", url = "https://mysite.atlassian.net") { + return { id, name, url, scopes: ["read:jira-work"] }; +} + +function mockSuccessfulExchange() { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "atl-access-tok", + sealed_refresh_token: "sealed-refresh-blob", + expires_in: 3600, + }), + })); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("JiraCallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + // Re-apply default mock return values cleared by vi.clearAllMocks() + vi.mocked(proxyLib.acquireTurnstileToken).mockResolvedValue("mock-turnstile-token"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + // ── Loading state ───────────────────────────────────────────────────────── + + it("shows loading state while exchange is in flight", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + vi.mocked(proxyLib.acquireTurnstileToken).mockResolvedValue("tok"); + vi.mocked(JiraClient.getAccessibleResources).mockResolvedValue([]); + + renderCallback(); + screen.getByText(/Connecting Jira/i); + }); + + // ── State / CSRF errors ─────────────────────────────────────────────────── + + it("shows error when state param is missing from URL", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code" }); // no state + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/Invalid OAuth state/i)).toBeTruthy(); + }); + }); + + it("shows error when state param does not match sessionStorage", async () => { + sessionStorage.setItem(JIRA_OAUTH_STATE_KEY, "expected-state"); + setWindowSearch({ code: "jira-code", state: "wrong-state" }); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/Invalid OAuth state/i)).toBeTruthy(); + }); + }); + + it("shows error when sessionStorage has no stored state", async () => { + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + // No sessionStorage.setItem — state key missing + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/Invalid OAuth state/i)).toBeTruthy(); + }); + }); + + it("sessionStorage state key is consumed (removed) after mount", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); // keep pending + + renderCallback(); + + await waitFor(() => { + expect(sessionStorage.getItem(JIRA_OAUTH_STATE_KEY)).toBeNull(); + }); + }); + + // ── Missing code ────────────────────────────────────────────────────────── + + it("shows error when code is missing from URL", async () => { + setupValidState(); + setWindowSearch({ state: "valid-jira-state" }); // no code + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/No authorization code/i)).toBeTruthy(); + }); + }); + + // ── Token exchange failures ─────────────────────────────────────────────── + + it("shows error when token exchange returns non-ok response", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_code" }), + })); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/Failed to complete Jira sign in/i)).toBeTruthy(); + }); + }); + + it("shows error when token exchange returns ok:true but body fails Zod schema parse", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ invalid: "shape", no_access_token: true }), + })); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/Failed to complete Jira sign in/i)).toBeTruthy(); + }); + }); + + it("shows error on network error during token exchange", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + vi.mocked(proxyLib.acquireTurnstileToken).mockResolvedValue("tok"); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/network error/i)).toBeTruthy(); + }); + }); + + it("shows error when Turnstile fails", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + vi.mocked(proxyLib.acquireTurnstileToken).mockRejectedValue(new Error("Turnstile failed")); + vi.stubGlobal("fetch", vi.fn()); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/Human verification failed/i)).toBeTruthy(); + }); + }); + + // ── Empty sites ─────────────────────────────────────────────────────────── + + it("shows error when no Jira sites found", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + mockSuccessfulExchange(); + vi.mocked(JiraClient.getAccessibleResources).mockResolvedValue([]); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/No Jira Cloud sites found/i)).toBeTruthy(); + }); + }); + + // ── Single site auto-select ─────────────────────────────────────────────── + + it("auto-selects single site and calls setJiraAuth + updateJiraConfig", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + mockSuccessfulExchange(); + vi.mocked(JiraClient.getAccessibleResources).mockResolvedValue([ + makeResource("cloud-abc", "My Site", "https://mysite.atlassian.net"), + ]); + + renderCallback(); + + await waitFor(() => { + expect(vi.mocked(authStore.setJiraAuth)).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "atl-access-tok", + sealedRefreshToken: "sealed-refresh-blob", + cloudId: "cloud-abc", + siteUrl: "https://mysite.atlassian.net", + siteName: "My Site", + }) + ); + }); + + expect(vi.mocked(configStore.updateJiraConfig)).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + cloudId: "cloud-abc", + authMethod: "oauth", + }) + ); + }); + + it("navigates to /settings after successful single-site auto-select", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + mockSuccessfulExchange(); + vi.mocked(JiraClient.getAccessibleResources).mockResolvedValue([ + makeResource("cloud-abc", "My Site", "https://mysite.atlassian.net"), + ]); + + render(() => ( + + +

SettingsLanded
} /> + + + )); + + await waitFor(() => { + expect(screen.getByText("SettingsLanded")).toBeTruthy(); + }); + }); + + it("exchange POST sends code in body and Turnstile token in header", async () => { + setupValidState(); + setWindowSearch({ code: "my-jira-code", state: "valid-jira-state" }); + vi.mocked(proxyLib.acquireTurnstileToken).mockResolvedValue("test-turnstile-tok"); + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "tok", + sealed_refresh_token: "s", + expires_in: 3600, + }), + }); + vi.stubGlobal("fetch", mockFetch); + vi.mocked(JiraClient.getAccessibleResources).mockResolvedValue([ + makeResource(), + ]); + + renderCallback(); + + await waitFor(() => { + expect(vi.mocked(authStore.setJiraAuth)).toHaveBeenCalled(); + }); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/oauth/jira/token"); + expect(JSON.parse(init.body as string)).toEqual({ code: "my-jira-code" }); + const headers = init.headers as Record; + expect(headers["cf-turnstile-response"]).toBe("test-turnstile-tok"); + }); + + // ── Multi-site picker ───────────────────────────────────────────────────── + + it("shows site picker when multiple Jira sites returned", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + mockSuccessfulExchange(); + vi.mocked(JiraClient.getAccessibleResources).mockResolvedValue([ + makeResource("cloud-a", "Site Alpha", "https://alpha.atlassian.net"), + makeResource("cloud-b", "Site Beta", "https://beta.atlassian.net"), + ]); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText("Site Alpha")).toBeTruthy(); + expect(screen.getByText("Site Beta")).toBeTruthy(); + }); + expect(screen.getByText(/Connect Jira Site/i)).toBeTruthy(); + }); + + it("setJiraAuth is NOT called before site picker selection", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + mockSuccessfulExchange(); + vi.mocked(JiraClient.getAccessibleResources).mockResolvedValue([ + makeResource("cloud-a", "Site Alpha"), + makeResource("cloud-b", "Site Beta"), + ]); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText("Site Alpha")).toBeTruthy(); + }); + expect(vi.mocked(authStore.setJiraAuth)).not.toHaveBeenCalled(); + }); + + it("selecting a site in the picker calls setJiraAuth + updateJiraConfig", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + mockSuccessfulExchange(); + vi.mocked(JiraClient.getAccessibleResources).mockResolvedValue([ + makeResource("cloud-a", "Site Alpha", "https://alpha.atlassian.net"), + makeResource("cloud-b", "Site Beta", "https://beta.atlassian.net"), + ]); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText("Site Beta")).toBeTruthy(); + }); + + // Click the "Site Beta" button + screen.getByText("Site Beta").closest("button")!.click(); + + await waitFor(() => { + expect(vi.mocked(authStore.setJiraAuth)).toHaveBeenCalledWith( + expect.objectContaining({ + cloudId: "cloud-b", + siteName: "Site Beta", + siteUrl: "https://beta.atlassian.net", + }) + ); + }); + expect(vi.mocked(configStore.updateJiraConfig)).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true, cloudId: "cloud-b", authMethod: "oauth" }) + ); + }); + + // ── Site discovery errors ───────────────────────────────────────────────── + + it("shows error when getAccessibleResources throws", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + mockSuccessfulExchange(); + vi.mocked(JiraClient.getAccessibleResources).mockRejectedValue(new Error("network error")); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/Failed to discover Jira sites/i)).toBeTruthy(); + }); + }); + + it("shows error when getAccessibleResources returns malformed data (Zod validation)", async () => { + setupValidState(); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + mockSuccessfulExchange(); + // Missing required fields: `id` and `scopes` are absent — Zod should reject this + vi.mocked(JiraClient.getAccessibleResources).mockResolvedValue([ + { name: "My Site", url: "https://mysite.atlassian.net" } as never, + ]); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/Unexpected response from Atlassian/i)).toBeTruthy(); + }); + }); + + it("shows return-to-settings link on error", async () => { + setupValidState("mismatch"); + setWindowSearch({ code: "jira-code", state: "valid-jira-state" }); + + renderCallback(); + + await waitFor(() => { + expect(screen.getByText(/Return to Settings/i)).toBeTruthy(); + }); + }); +}); diff --git a/tests/security/headers.test.ts b/tests/security/headers.test.ts new file mode 100644 index 00000000..6667d834 --- /dev/null +++ b/tests/security/headers.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Parses Cloudflare _headers file and returns headers for a given path pattern. + * The file format is: + * /path-pattern + * Header-Name: value + * Header-Name2: value2 + */ +function parseHeadersFile(content: string): Map> { + const result = new Map>(); + let currentPath: string | null = null; + + for (const rawLine of content.split("\n")) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith("#")) continue; + + // Lines starting with / or * are path patterns + if (/^[/*]/.test(line) && !line.startsWith(" ") && !line.startsWith("\t")) { + currentPath = line.trim(); + result.set(currentPath, new Map()); + } else if (currentPath !== null && (line.startsWith(" ") || line.startsWith("\t"))) { + const colonIdx = line.indexOf(":"); + if (colonIdx !== -1) { + const name = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + result.get(currentPath)!.set(name, value); + } + } + } + + return result; +} + +/** Parse a CSP header value into a directive map. */ +function parseCsp(cspValue: string): Map { + const directives = new Map(); + for (const part of cspValue.split(";")) { + const trimmed = part.trim(); + if (!trimmed) continue; + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx === -1) { + directives.set(trimmed, ""); + } else { + directives.set(trimmed.slice(0, spaceIdx), trimmed.slice(spaceIdx + 1)); + } + } + return directives; +} + +// ── _headers file tests ─────────────────────────────────────────────────────── + +describe("public/_headers CSP validation", () => { + const headersPath = resolve(__dirname, "../../public/_headers"); + const headersContent = readFileSync(headersPath, "utf-8"); + const headersMap = parseHeadersFile(headersContent); + + // The wildcard path /* covers all pages + // If not found, look for the root path + function getCspForPath(path: string): string | undefined { + const headers = headersMap.get(path); + return headers?.get("Content-Security-Policy"); + } + + // Find the CSP from the wildcard entry (/*) since that's how Cloudflare Pages applies it + const rawCsp = getCspForPath("/*") ?? getCspForPath("/"); + const csp = rawCsp ? parseCsp(rawCsp) : null; + + it("_headers file can be read and parsed", () => { + expect(headersContent.length).toBeGreaterThan(0); + expect(csp).not.toBeNull(); + }); + + it("connect-src includes https://api.atlassian.com", () => { + expect(csp).not.toBeNull(); + const connectSrc = csp!.get("connect-src") ?? ""; + expect(connectSrc).toContain("https://api.atlassian.com"); + }); + + it("connect-src does NOT include https://auth.atlassian.com", () => { + // auth.atlassian.com is only used for server-side OAuth — browser never fetch()es it + // (OAuth consent is a page navigation; token exchange goes through Worker server-side) + expect(csp).not.toBeNull(); + const connectSrc = csp!.get("connect-src") ?? ""; + expect(connectSrc).not.toContain("https://auth.atlassian.com"); + }); + + it("connect-src still includes https://api.github.com (not accidentally removed)", () => { + expect(csp).not.toBeNull(); + const connectSrc = csp!.get("connect-src") ?? ""; + expect(connectSrc).toContain("https://api.github.com"); + }); + + it("connect-src includes 'self' (same-origin Worker calls)", () => { + expect(csp).not.toBeNull(); + const connectSrc = csp!.get("connect-src") ?? ""; + expect(connectSrc).toContain("'self'"); + }); + + it("default-src is 'none' (deny-by-default)", () => { + expect(csp).not.toBeNull(); + const defaultSrc = csp!.get("default-src") ?? ""; + expect(defaultSrc).toContain("'none'"); + }); + + it("frame-ancestors is 'none' (no embedding allowed)", () => { + expect(csp).not.toBeNull(); + const frameAncestors = csp!.get("frame-ancestors") ?? ""; + expect(frameAncestors).toContain("'none'"); + }); + + it("X-Content-Type-Options is nosniff", () => { + const headers = headersMap.get("/*"); + expect(headers?.get("X-Content-Type-Options")).toBe("nosniff"); + }); + + it("X-Frame-Options is DENY", () => { + const headers = headersMap.get("/*"); + expect(headers?.get("X-Frame-Options")).toBe("DENY"); + }); + + it("Strict-Transport-Security header is present", () => { + const headers = headersMap.get("/*"); + const hsts = headers?.get("Strict-Transport-Security") ?? ""; + expect(hsts).toContain("max-age="); + expect(hsts).toContain("includeSubDomains"); + }); +}); diff --git a/tests/services/api-optimization.test.ts b/tests/services/api-optimization.test.ts index a7559a61..87cdf61a 100644 --- a/tests/services/api-optimization.test.ts +++ b/tests/services/api-optimization.test.ts @@ -24,7 +24,7 @@ const graphqlIssueNode = { databaseId: 1347, number: 1347, title: "Found a bug", - state: "open", + state: "OPEN", url: "https://github.com/octocat/Hello-World/issues/1347", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-02T00:00:00Z", @@ -45,7 +45,7 @@ const graphqlLightPRNodeDefaults = { databaseId: 42, number: 42, title: "Add feature", - state: "open", + state: "OPEN", isDraft: false, url: "https://github.com/octocat/Hello-World/pull/42", createdAt: "2024-01-01T00:00:00Z", diff --git a/tests/services/api-usage.test.ts b/tests/services/api-usage.test.ts index 08d4a33f..e74e7d49 100644 --- a/tests/services/api-usage.test.ts +++ b/tests/services/api-usage.test.ts @@ -96,7 +96,7 @@ describe("trackApiCall — increment and record creation", () => { }); it("tracks separate records for different pool types", () => { - mod.trackApiCall("notifications", "core"); + mod.trackApiCall("userEvents", "core"); mod.trackApiCall("lightSearch", "graphql"); const snapshot = mod.getUsageSnapshot(); expect(snapshot).toHaveLength(2); @@ -125,7 +125,7 @@ describe("getUsageSnapshot — sorting", () => { }); it("returns records sorted by count descending", () => { - mod.trackApiCall("notifications", "core", 1); + mod.trackApiCall("userEvents", "core", 1); mod.trackApiCall("lightSearch", "graphql", 5); mod.trackApiCall("workflowRuns", "core", 3); const snapshot = mod.getUsageSnapshot(); @@ -136,7 +136,7 @@ describe("getUsageSnapshot — sorting", () => { it("tiebreaks by lastCalledAt descending when counts are equal", () => { vi.setSystemTime(new Date("2026-01-01T10:00:00Z")); - mod.trackApiCall("notifications", "core", 2); + mod.trackApiCall("userEvents", "core", 2); vi.setSystemTime(new Date("2026-01-01T10:00:10Z")); mod.trackApiCall("lightSearch", "graphql", 2); @@ -144,7 +144,7 @@ describe("getUsageSnapshot — sorting", () => { const snapshot = mod.getUsageSnapshot(); // lightSearch called more recently — should be first expect(snapshot[0].source).toBe("lightSearch"); - expect(snapshot[1].source).toBe("notifications"); + expect(snapshot[1].source).toBe("userEvents"); }); }); @@ -440,8 +440,8 @@ describe("deriveSource — URL pattern matching", () => { } it.each([ - ["/notifications", "notifications"], - ["/notifications?per_page=1", "notifications"], + ["/users/testuser/events", "userEvents"], + ["/users/testuser/events?per_page=100", "userEvents"], ["/users/octocat", "validateUser"], ["/user", "fetchOrgs"], ["/user/orgs", "fetchOrgs"], diff --git a/tests/services/api.test.ts b/tests/services/api.test.ts index 05f3ee17..b83f00a5 100644 --- a/tests/services/api.test.ts +++ b/tests/services/api.test.ts @@ -707,7 +707,7 @@ describe("fetchIssuesAndPullRequests — all repos monitored (edge case)", () => databaseId: 3001, number: 1, title: "All-monitored issue", - state: "open", + state: "OPEN", url: "https://github.com/org/repo1/issues/1", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-02T00:00:00Z", @@ -770,7 +770,7 @@ describe("fetchIssuesAndPullRequests — cross-feature: monitored repo + bot tra databaseId: 2001, number: 1, title: "Monitored repo issue", - state: "open", + state: "OPEN", url: "https://github.com/org/monitored/issues/1", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-02T00:00:00Z", @@ -785,7 +785,7 @@ describe("fetchIssuesAndPullRequests — cross-feature: monitored repo + bot tra databaseId: 2002, number: 2, title: "Bot-surfaced issue", - state: "open", + state: "OPEN", url: "https://github.com/org/normal/issues/2", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-02T00:00:00Z", @@ -894,7 +894,7 @@ describe("fetchIssuesAndPullRequests — unfiltered search error handling", () = databaseId: 4001, number: 1, title: "Partial issue", - state: "open", + state: "OPEN", url: "https://github.com/org/monitored/issues/1", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-02T00:00:00Z", @@ -996,7 +996,7 @@ describe("fetchIssuesAndPullRequests — unfiltered search error handling", () = databaseId: 4501, number: 1, title: "Partial PR", - state: "open", + state: "OPEN", isDraft: false, url: "https://github.com/org/monitored/pull/1", createdAt: "2024-01-01T00:00:00Z", @@ -1099,7 +1099,7 @@ describe("fetchIssuesAndPullRequests — onLightData suppression when all monito databaseId: 5001, number: 1, title: "Monitored issue", - state: "open", + state: "OPEN", url: "https://github.com/org/repo1/issues/1", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-02T00:00:00Z", diff --git a/tests/services/events-poll.test.ts b/tests/services/events-poll.test.ts new file mode 100644 index 00000000..65a59a6e --- /dev/null +++ b/tests/services/events-poll.test.ts @@ -0,0 +1,823 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createRoot } from "solid-js"; +import { makePullRequest, makeWorkflowRun } from "../helpers/index"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockGetClient = vi.fn(); +vi.mock("../../src/app/services/github", () => ({ + getClient: () => mockGetClient(), + fetchRateLimitDetails: vi.fn(() => Promise.resolve(null)), + cachedRequest: vi.fn(), + updateGraphqlRateLimit: vi.fn(), + updateRateLimitFromHeaders: vi.fn(), + onApiRequest: vi.fn(), + initClientWatcher: vi.fn(), +})); + +vi.mock("../../src/app/lib/errors", () => ({ + pushError: vi.fn(), + clearErrors: vi.fn(), + getErrors: vi.fn(() => []), + getNotifications: vi.fn(() => []), + dismissNotificationBySource: vi.fn(), + startCycleTracking: vi.fn(), + endCycleTracking: vi.fn(() => new Set()), + pushNotification: vi.fn(), + clearNotifications: vi.fn(), + resetNotificationState: vi.fn(), + addMutedSource: vi.fn(), + isMuted: vi.fn(() => false), + clearMutedSources: vi.fn(), +})); + +vi.mock("../../src/app/lib/notifications", () => ({ + detectNewItems: vi.fn(() => []), + dispatchNotifications: vi.fn(), + _resetNotificationState: vi.fn(), +})); + +const mockFetchUserEvents = vi.fn(); +const mockResetEventsState = vi.fn(); +const mockParseRepoEvents = vi.fn(); + +vi.mock("../../src/app/services/events", () => ({ + fetchUserEvents: (...args: unknown[]) => mockFetchUserEvents(...args), + parseRepoEvents: (...args: unknown[]) => mockParseRepoEvents(...args), + resetEventsState: () => mockResetEventsState(), +})); + +const mockFetchIssuesAndPullRequests = vi.fn(); +const mockFetchWorkflowRuns = vi.fn(); +vi.mock("../../src/app/services/api", () => ({ + fetchIssuesAndPullRequests: (...args: unknown[]) => mockFetchIssuesAndPullRequests(...args), + fetchWorkflowRuns: (...args: unknown[]) => mockFetchWorkflowRuns(...args), + fetchHotPRStatus: vi.fn(async () => ({ results: new Map(), hadErrors: false })), + fetchWorkflowRunById: vi.fn(async () => ({ id: 1, status: "completed", conclusion: "success", updatedAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:05:00Z" })), + pooledAllSettled: vi.fn(async (tasks: (() => Promise)[]) => { + const results = await Promise.allSettled(tasks.map((t) => t())); + return results; + }), + resetEmptyActionRepos: vi.fn(), +})); + +import { fetchHotPRStatus, fetchWorkflowRunById } from "../../src/app/services/api"; + +vi.mock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + hotPollInterval: 30, + trackedUsers: [], + monitoredRepos: [], + }, +})); + +vi.mock("../../src/app/stores/auth", () => ({ + user: vi.fn(() => null), + onAuthCleared: vi.fn(), +})); + +vi.mock("../../src/app/services/api-usage", () => ({ + checkAndResetIfExpired: vi.fn(), +})); + +vi.mock("@sentry/solid", () => ({ + captureException: vi.fn(), +})); + +// Import AFTER mocks +import { + resetPollState, + fetchTargetedRepoData, + fetchHotData, + seedHotSetsFromTargeted, + createEventsPollCoordinator, + getHotPollGeneration, + clearHotSets, + rebuildHotSets, + type DashboardData, +} from "../../src/app/services/poll"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const emptyData: DashboardData = { + issues: [], + pullRequests: [], + workflowRuns: [], + errors: [], +}; + +function makeOctokit() { + return { + request: vi.fn(() => Promise.resolve({ data: {}, headers: {} })), + graphql: vi.fn(() => Promise.resolve({ nodes: [], rateLimit: { limit: 5000, remaining: 4999, resetAt: "2026-01-01T00:00:00Z" } })), + hook: { before: vi.fn() }, + }; +} + +function makeRepoSummary(overrides: { + repoFullName?: string; + hasIssueActivity?: boolean; + hasPRActivity?: boolean; + hasWorkflowActivity?: boolean; + latestEventAt?: string; +} = {}) { + return { + repoFullName: overrides.repoFullName ?? "owner/repo", + eventTypes: new Set(), + hasIssueActivity: overrides.hasIssueActivity ?? false, + hasPRActivity: overrides.hasPRActivity ?? false, + hasWorkflowActivity: overrides.hasWorkflowActivity ?? false, + latestEventAt: overrides.latestEventAt ?? "2026-01-01T00:00:00Z", + }; +} + +async function flushPromises(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("fetchTargetedRepoData", () => { + beforeEach(() => { + resetPollState(); + vi.clearAllMocks(); + mockFetchIssuesAndPullRequests.mockResolvedValue({ issues: [], pullRequests: [], errors: [] }); + mockFetchWorkflowRuns.mockResolvedValue({ workflowRuns: [], errors: [] }); + }); + + it("returns empty data when no octokit client", async () => { + mockGetClient.mockReturnValue(null); + const summaries = new Map([["owner/repo", makeRepoSummary()]]); + + const result = await fetchTargetedRepoData(summaries); + + expect(result.issues).toHaveLength(0); + expect(result.pullRequests).toHaveLength(0); + expect(mockFetchIssuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("calls fetchIssuesAndPullRequests with target repos", async () => { + mockGetClient.mockReturnValue(makeOctokit()); + const summaries = new Map([ + ["owner/repo-a", makeRepoSummary({ repoFullName: "owner/repo-a" })], + ]); + + await fetchTargetedRepoData(summaries); + + expect(mockFetchIssuesAndPullRequests).toHaveBeenCalledTimes(1); + const calledRepos = mockFetchIssuesAndPullRequests.mock.calls[0][1] as Array<{ owner: string; name: string }>; + expect(calledRepos).toContainEqual(expect.objectContaining({ owner: "owner", name: "repo-a" })); + }); + + it("calls fetchWorkflowRuns only for repos with hasWorkflowActivity=true", async () => { + mockGetClient.mockReturnValue(makeOctokit()); + const summaries = new Map([ + ["owner/repo-a", makeRepoSummary({ repoFullName: "owner/repo-a", hasWorkflowActivity: true })], + ["owner/repo-b", makeRepoSummary({ repoFullName: "owner/repo-b", hasWorkflowActivity: false })], + ]); + + await fetchTargetedRepoData(summaries); + + expect(mockFetchWorkflowRuns).toHaveBeenCalledTimes(1); + const workflowRepos = mockFetchWorkflowRuns.mock.calls[0][1] as Array<{ owner: string; name: string }>; + expect(workflowRepos).toHaveLength(1); + expect(workflowRepos[0]).toMatchObject({ owner: "owner", name: "repo-a" }); + }); + + it("skips fetchWorkflowRuns when no repos have hasWorkflowActivity", async () => { + mockGetClient.mockReturnValue(makeOctokit()); + const summaries = new Map([ + ["owner/repo", makeRepoSummary({ hasWorkflowActivity: false })], + ]); + + await fetchTargetedRepoData(summaries); + + expect(mockFetchWorkflowRuns).not.toHaveBeenCalled(); + }); + + it("caps targeted repos at MAX_TARGETED_REPOS=10 and selects the 10 most recent by latestEventAt", async () => { + mockGetClient.mockReturnValue(makeOctokit()); + + const summaries = new Map>(); + for (let i = 0; i < 12; i++) { + const name = `owner/repo-${i}`; + const ts = i < 2 + ? `2026-01-0${i + 1}T00:00:00Z` + : `2026-02-${String(i).padStart(2, "0")}T00:00:00Z`; + summaries.set(name.toLowerCase(), makeRepoSummary({ repoFullName: name, latestEventAt: ts })); + } + + await fetchTargetedRepoData(summaries); + + const calledRepos = mockFetchIssuesAndPullRequests.mock.calls[0][1] as Array<{ owner: string; name: string }>; + expect(calledRepos).toHaveLength(10); + + const calledNames = calledRepos.map((r) => r.name); + expect(calledNames).not.toContain("repo-0"); + expect(calledNames).not.toContain("repo-1"); + }); + + it("applies per-repo cooldown: skips repos targeted within TARGETED_COOLDOWN_MS", async () => { + mockGetClient.mockReturnValue(makeOctokit()); + const summaries = new Map([ + ["owner/repo", makeRepoSummary({ repoFullName: "owner/repo" })], + ]); + + // First call — repo is targeted + await fetchTargetedRepoData(summaries); + const firstCallRepos = mockFetchIssuesAndPullRequests.mock.calls[0][1] as unknown[]; + expect(firstCallRepos).toHaveLength(1); + + // Second immediate call — repo is on cooldown, should be skipped + mockFetchIssuesAndPullRequests.mockClear(); + await fetchTargetedRepoData(summaries); + + // fetchTargetedRepoData returns early (entries.length === 0) without calling fetchIssuesAndPullRequests + expect(mockFetchIssuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("re-targets repo after TARGETED_COOLDOWN_MS has elapsed", async () => { + vi.useFakeTimers(); + try { + mockGetClient.mockReturnValue(makeOctokit()); + const summaries = new Map([ + ["owner/repo", makeRepoSummary({ repoFullName: "owner/repo" })], + ]); + + await fetchTargetedRepoData(summaries); + expect(mockFetchIssuesAndPullRequests).toHaveBeenCalledTimes(1); + + vi.setSystemTime(Date.now() + 120_001); // TARGETED_COOLDOWN_MS + 1ms + mockFetchIssuesAndPullRequests.mockClear(); + + await fetchTargetedRepoData(summaries); + expect(mockFetchIssuesAndPullRequests).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); + +// ── seedHotSetsFromTargeted ─────────────────────────────────────────────────── + +describe("seedHotSetsFromTargeted", () => { + beforeEach(() => { + resetPollState(); + mockGetClient.mockReturnValue(makeOctokit()); + vi.mocked(fetchHotPRStatus).mockClear(); + vi.mocked(fetchWorkflowRunById).mockClear(); + }); + + it("adds enriched pending-checkStatus PRs with nodeId to hot set", async () => { + seedHotSetsFromTargeted({ + ...emptyData, + pullRequests: [ + makePullRequest({ id: 1, checkStatus: "pending", enriched: true, nodeId: "PR_a" }), + ], + }); + + await fetchHotData(); + + // fetchHotPRStatus should be called with the seeded node ID + expect(fetchHotPRStatus).toHaveBeenCalledTimes(1); + const calledNodeIds = vi.mocked(fetchHotPRStatus).mock.calls[0][1] as string[]; + expect(calledNodeIds).toContain("PR_a"); + }); + + it("does NOT add PRs with checkStatus=null to hot set", async () => { + seedHotSetsFromTargeted({ + ...emptyData, + pullRequests: [ + makePullRequest({ id: 2, checkStatus: null, enriched: true, nodeId: "PR_b" }), + ], + }); + + await fetchHotData(); + + // No PRs in hot set — fetchHotPRStatus not called + expect(fetchHotPRStatus).not.toHaveBeenCalled(); + }); + + it("does NOT add PRs that are not enriched", async () => { + seedHotSetsFromTargeted({ + ...emptyData, + pullRequests: [ + makePullRequest({ id: 3, checkStatus: "pending", enriched: false, nodeId: "PR_c" }), + ], + }); + + await fetchHotData(); + + expect(fetchHotPRStatus).not.toHaveBeenCalled(); + }); + + it("does NOT remove existing hot items (additive only)", async () => { + // Seed existing hot set via rebuildHotSets + rebuildHotSets({ + ...emptyData, + pullRequests: [ + makePullRequest({ id: 10, checkStatus: "pending", enriched: true, nodeId: "PR_existing" }), + ], + }); + + // seedHotSetsFromTargeted adds new PR without clearing the existing one + seedHotSetsFromTargeted({ + ...emptyData, + pullRequests: [ + makePullRequest({ id: 11, checkStatus: "pending", enriched: true, nodeId: "PR_new" }), + ], + }); + + await fetchHotData(); + + expect(fetchHotPRStatus).toHaveBeenCalledTimes(1); + const calledNodeIds = vi.mocked(fetchHotPRStatus).mock.calls[0][1] as string[]; + expect(calledNodeIds).toContain("PR_existing"); + expect(calledNodeIds).toContain("PR_new"); + }); + + it("does NOT increment _hotPollGeneration", () => { + const genBefore = getHotPollGeneration(); + + seedHotSetsFromTargeted({ + ...emptyData, + pullRequests: [ + makePullRequest({ id: 20, checkStatus: "pending", enriched: true, nodeId: "PR_gen" }), + ], + }); + + expect(getHotPollGeneration()).toBe(genBefore); + }); + + it("adds queued/in_progress workflow runs to hot set", async () => { + seedHotSetsFromTargeted({ + ...emptyData, + workflowRuns: [ + makeWorkflowRun({ id: 42, status: "in_progress", conclusion: null, repoFullName: "owner/repo" }), + makeWorkflowRun({ id: 43, status: "queued", conclusion: null, repoFullName: "owner/repo" }), + ], + }); + + await fetchHotData(); + + // fetchWorkflowRunById called once per run via pooledAllSettled + expect(fetchWorkflowRunById).toHaveBeenCalledTimes(2); + }); +}); + +// ── createEventsPollCoordinator ─────────────────────────────────────────────── + +describe("createEventsPollCoordinator", () => { + beforeEach(() => { + vi.useFakeTimers(); + resetPollState(); + vi.clearAllMocks(); + mockGetClient.mockReturnValue(makeOctokit()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires first cycle immediately (delay=0)", async () => { + mockFetchUserEvents.mockResolvedValue({ events: [], changed: false }); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + vi.fn(), + ); + dispose(); + }); + + // Trigger the immediate setTimeout(..., 0) then clean up + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + expect(mockFetchUserEvents).toHaveBeenCalledTimes(1); + }); + + it("calls onTargetedData when events indicate changes in tracked repos", async () => { + const event = { + id: "100", + type: "IssuesEvent", + actor: { id: 1, login: "user" }, + repo: { id: 1, name: "owner/repo" }, + payload: {}, + created_at: "2026-01-01T00:00:00Z", + }; + mockFetchUserEvents.mockResolvedValue({ events: [event], changed: true }); + mockParseRepoEvents.mockReturnValue( + new Map([["owner/repo", makeRepoSummary({ repoFullName: "owner/repo" })]]) + ); + mockFetchIssuesAndPullRequests.mockResolvedValue({ issues: [], pullRequests: [], errors: [] }); + + const onTargetedData = vi.fn(); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + onTargetedData, + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + expect(onTargetedData).toHaveBeenCalledTimes(1); + }); + + it("does NOT call onTargetedData when changed=false", async () => { + mockFetchUserEvents.mockResolvedValue({ events: [], changed: false }); + + const onTargetedData = vi.fn(); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + onTargetedData, + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + expect(onTargetedData).not.toHaveBeenCalled(); + }); + + it("does NOT call parseRepoEvents when changed=true but events.length=0 (defense-in-depth)", async () => { + // After the fetchUserEvents fix, changed=true with empty events can't occur in production. + // This tests the coordinator's defensive || guard at poll.ts: if (!changed || events.length === 0). + mockFetchUserEvents.mockResolvedValue({ events: [], changed: true }); + + const onTargetedData = vi.fn(); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + onTargetedData, + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + expect(mockFetchUserEvents).toHaveBeenCalledTimes(1); + expect(mockParseRepoEvents).not.toHaveBeenCalled(); + expect(onTargetedData).not.toHaveBeenCalled(); + }); + + it("does NOT call onTargetedData when parseRepoEvents returns empty map (untracked repos)", async () => { + const event = { + id: "300", + type: "IssuesEvent", + actor: { id: 1, login: "user" }, + repo: { id: 1, name: "other/untracked" }, + payload: {}, + created_at: "2026-01-01T00:00:00Z", + }; + mockFetchUserEvents.mockResolvedValue({ events: [event], changed: true }); + mockParseRepoEvents.mockReturnValue(new Map()); + + const onTargetedData = vi.fn(); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + onTargetedData, + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + expect(mockFetchUserEvents).toHaveBeenCalledTimes(1); + expect(mockParseRepoEvents).toHaveBeenCalledTimes(1); + expect(onTargetedData).not.toHaveBeenCalled(); + }); + + it("skips cycle when isFullRefreshing becomes true after fetchUserEvents resolves", async () => { + const event = { + id: "200", + type: "IssuesEvent", + actor: { id: 1, login: "user" }, + repo: { id: 1, name: "owner/repo" }, + payload: {}, + created_at: "2026-01-01T00:00:00Z", + }; + mockFetchUserEvents.mockResolvedValue({ events: [event], changed: true }); + mockParseRepoEvents.mockReturnValue( + new Map([["owner/repo", makeRepoSummary({ repoFullName: "owner/repo" })]]) + ); + + const isFullRefreshing = vi.fn().mockReturnValueOnce(false).mockReturnValue(true); + const onTargetedData = vi.fn(); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + isFullRefreshing, + onTargetedData, + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + expect(mockFetchUserEvents).toHaveBeenCalledTimes(1); + expect(mockFetchIssuesAndPullRequests).not.toHaveBeenCalled(); + expect(onTargetedData).not.toHaveBeenCalled(); + }); + + it("skips cycle when isFullRefreshing=true", async () => { + mockFetchUserEvents.mockResolvedValue({ events: [], changed: false }); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => true, // full refresh in progress + vi.fn(), + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + // fetchUserEvents not called when isFullRefreshing=true + expect(mockFetchUserEvents).not.toHaveBeenCalled(); + }); + + it("skips cycle when username is empty", async () => { + mockFetchUserEvents.mockResolvedValue({ events: [], changed: false }); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "", + () => new Set(["owner/repo"]), + () => false, + vi.fn(), + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + expect(mockFetchUserEvents).not.toHaveBeenCalled(); + }); + + it("skips cycle when no octokit client", async () => { + mockGetClient.mockReturnValue(null); + mockFetchUserEvents.mockResolvedValue({ events: [], changed: false }); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + vi.fn(), + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + expect(mockFetchUserEvents).not.toHaveBeenCalled(); + }); + + it("destroy before first cycle fires prevents any cycle from running", async () => { + mockFetchUserEvents.mockResolvedValue({ events: [], changed: false }); + + let coordinator: { destroy: () => void } | null = null; + + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + vi.fn(), + ); + dispose(); + }); + + coordinator!.destroy(); + + vi.advanceTimersByTime(300_000); + await flushPromises(); + + expect(mockFetchUserEvents).not.toHaveBeenCalled(); + }); + + it("destroy after initial cycle fires stops all subsequent cycles", async () => { + mockFetchUserEvents.mockResolvedValue({ events: [], changed: false }); + + let coordinator: { destroy: () => void } | null = null; + + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + vi.fn(), + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + + expect(mockFetchUserEvents).toHaveBeenCalledTimes(1); + + coordinator!.destroy(); + + vi.advanceTimersByTime(300_000); + await flushPromises(); + + expect(mockFetchUserEvents).toHaveBeenCalledTimes(1); + }); + + it("applies exponential backoff after consecutive failures", async () => { + mockFetchUserEvents.mockRejectedValue(new Error("API error")); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + vi.fn(), + ); + dispose(); + }); + + // Trigger first cycle (delay=0) + vi.advanceTimersByTime(0); + await flushPromises(); + + // After first error, backoff = 2^1 = 2x base interval (60s * 2 = 120s). + // Advancing 60s should NOT trigger the next cycle yet. + const callsAtBase = mockFetchUserEvents.mock.calls.length; + vi.advanceTimersByTime(60_000); + await flushPromises(); + + expect(mockFetchUserEvents.mock.calls.length).toBe(callsAtBase); + + // Advancing the remaining 60s (total 120s) should trigger it + vi.advanceTimersByTime(60_000); + await flushPromises(); + + expect(mockFetchUserEvents.mock.calls.length).toBeGreaterThan(callsAtBase); + coordinator!.destroy(); + }); + + it("resets backoff to base interval after a successful cycle following failures", async () => { + // First cycle: error → consecutiveFailures = 1 + mockFetchUserEvents.mockRejectedValueOnce(new Error("API error")); + // Second cycle: success → consecutiveFailures = 0, next schedule at base interval + mockFetchUserEvents.mockResolvedValue({ events: [], changed: false }); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + vi.fn(), + ); + dispose(); + }); + + // First cycle (delay=0) — errors + vi.advanceTimersByTime(0); + await flushPromises(); + const callsAfterError = mockFetchUserEvents.mock.calls.length; + expect(callsAfterError).toBe(1); + + // After error: backoff = 2^1 = 2x → next at 120s + // Advance 120s to trigger the recovery cycle + vi.advanceTimersByTime(120_000); + await flushPromises(); + expect(mockFetchUserEvents.mock.calls.length).toBe(2); + + // After success: consecutiveFailures = 0, backoff = 2^0 = 1x → next at 60s + const callsAfterRecovery = mockFetchUserEvents.mock.calls.length; + vi.advanceTimersByTime(60_000); + await flushPromises(); + + // Should fire at base interval, not backed-off interval + expect(mockFetchUserEvents.mock.calls.length).toBeGreaterThan(callsAfterRecovery); + coordinator!.destroy(); + }); + + it("discards targeted data when hot poll generation changes during fetchTargetedRepoData", async () => { + const event = { + id: "400", + type: "IssuesEvent", + actor: { id: 1, login: "user" }, + repo: { id: 1, name: "owner/repo" }, + payload: {}, + created_at: "2026-01-01T00:00:00Z", + }; + mockFetchUserEvents.mockResolvedValue({ events: [event], changed: true }); + mockParseRepoEvents.mockReturnValue( + new Map([["owner/repo", makeRepoSummary({ repoFullName: "owner/repo" })]]) + ); + // Simulate a full refresh completing during fetchTargetedRepoData: + // rebuildHotSets increments _hotPollGeneration, so we call it inside + // the mock to simulate concurrent full refresh + mockFetchIssuesAndPullRequests.mockImplementation(async () => { + rebuildHotSets(emptyData); // increments _hotPollGeneration + return { issues: [], pullRequests: [], errors: [] }; + }); + + const onTargetedData = vi.fn(); + + let coordinator: { destroy: () => void }; + createRoot((dispose) => { + coordinator = createEventsPollCoordinator( + () => "testuser", + () => new Set(["owner/repo"]), + () => false, + onTargetedData, + ); + dispose(); + }); + + vi.advanceTimersByTime(0); + await flushPromises(); + coordinator!.destroy(); + + // fetchTargetedRepoData ran (fetchIssuesAndPullRequests was called), + // but generation changed during the fetch → targeted data discarded + expect(mockFetchIssuesAndPullRequests).toHaveBeenCalled(); + expect(onTargetedData).not.toHaveBeenCalled(); + }); +}); + +// ── Config-change effects ───────────────────────────────────────────────────── + +describe("config-change effects (QA-007)", () => { + // These effects are registered at module load via createRoot in poll.ts. + // We test them by checking resetEventsState is called when config signals change. + // Because the config mock is a plain object (not reactive), we test the + // resetEventsState integration via resetPollState() which calls it directly. + + it("resetPollState calls resetEventsState (integration: resetEventsState is part of full reset)", () => { + // resetPollState is what gets called on auth clear, and it internally calls resetEventsState. + // Verify the module wiring is correct by checking resetPollState resets module state. + resetPollState(); + + // After resetPollState, the generation is 0 (resetEventsState clears ETag/lastEventId) + expect(getHotPollGeneration()).toBe(0); + expect(mockResetEventsState).toHaveBeenCalled(); + }); + + it("clearHotSets does NOT increment generation (different from rebuildHotSets)", () => { + rebuildHotSets(emptyData); + expect(getHotPollGeneration()).toBe(1); + + clearHotSets(); + // clearHotSets clears sets but does not touch generation + expect(getHotPollGeneration()).toBe(1); + }); +}); diff --git a/tests/services/events.test.ts b/tests/services/events.test.ts new file mode 100644 index 00000000..2e673711 --- /dev/null +++ b/tests/services/events.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock auth store — events.ts calls onAuthCleared() at module scope +vi.mock("../../src/app/stores/auth", () => ({ + onAuthCleared: vi.fn(), + user: vi.fn(() => null), +})); + +// Mock github module (not directly used by events.ts, but imported transitively) +vi.mock("../../src/app/services/github", () => ({ + getClient: vi.fn(() => null), +})); + +// Import AFTER mocks +import { fetchUserEvents, parseRepoEvents, resetEventsState } from "../../src/app/services/events"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeOctokit(requestImpl: (...args: unknown[]) => unknown) { + return { + request: vi.fn(requestImpl), + hook: { before: vi.fn() }, + }; +} + +function makeEvent(overrides: { + id?: string; + type?: string; + repoName?: string; + created_at?: string; +} = {}) { + return { + id: overrides.id ?? "100", + type: overrides.type ?? "PushEvent", + actor: { id: 1, login: "user" }, + repo: { id: 1, name: overrides.repoName ?? "owner/repo" }, + payload: {}, + created_at: overrides.created_at ?? "2026-01-01T00:00:00Z", + }; +} + +// ── fetchUserEvents ─────────────────────────────────────────────────────────── + +describe("fetchUserEvents", () => { + beforeEach(() => { + resetEventsState(); + vi.clearAllMocks(); + }); + + it("returns events and changed=true on 200 response", async () => { + const event = makeEvent({ id: "500" }); + const octokit = makeOctokit(() => + Promise.resolve({ + data: [event], + headers: { etag: '"abc123"' }, + }) + ); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + expect(result.changed).toBe(true); + expect(result.events).toHaveLength(1); + expect(result.events[0].id).toBe("500"); + }); + + it("returns empty events and changed=false on 304", async () => { + const octokit = makeOctokit(() => Promise.reject({ status: 304 })); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + expect(result.changed).toBe(false); + expect(result.events).toHaveLength(0); + }); + + it("returns empty events and changed=false on network error without throwing", async () => { + const octokit = makeOctokit(() => Promise.reject(new Error("Network failure"))); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + expect(result.changed).toBe(false); + expect(result.events).toHaveLength(0); + }); + + it("sends If-None-Match header on second call after ETag received", async () => { + const octokit = makeOctokit(() => + Promise.resolve({ + data: [makeEvent({ id: "200" })], + headers: { etag: '"etag-value"' }, + }) + ); + + // First call — seeds ETag + await fetchUserEvents(octokit as never, "someuser"); + + // Second call — ETag should be sent + await fetchUserEvents(octokit as never, "someuser"); + + const secondCallHeaders = (octokit.request.mock.calls[1][1] as { headers?: Record }).headers ?? {}; + expect(secondCallHeaders["If-None-Match"]).toBe('"etag-value"'); + }); + + it("does NOT send If-None-Match on first call", async () => { + const octokit = makeOctokit(() => + Promise.resolve({ data: [], headers: {} }) + ); + + await fetchUserEvents(octokit as never, "someuser"); + + const firstCallHeaders = (octokit.request.mock.calls[0][1] as { headers?: Record }).headers ?? {}; + expect(firstCallHeaders["If-None-Match"]).toBeUndefined(); + }); + + it("returns all events on first call (no ID filter)", async () => { + const events = [ + makeEvent({ id: "300" }), + makeEvent({ id: "299" }), + makeEvent({ id: "298" }), + ]; + const octokit = makeOctokit(() => + Promise.resolve({ data: events, headers: {} }) + ); + + const result = await fetchUserEvents(octokit as never, "someuser"); + + expect(result.events).toHaveLength(3); + expect(result.changed).toBe(true); + }); + + it("filters to only events with IDs > lastEventId on subsequent calls", async () => { + // First call: seed lastEventId = "300" + const firstOctokit = makeOctokit(() => + Promise.resolve({ + data: [makeEvent({ id: "300" })], + headers: {}, + }) + ); + await fetchUserEvents(firstOctokit as never, "someuser"); + + // Second call: events with IDs 301 (new) and 299 (old) + const secondOctokit = makeOctokit(() => + Promise.resolve({ + data: [makeEvent({ id: "301" }), makeEvent({ id: "299" })], + headers: {}, + }) + ); + const result = await fetchUserEvents(secondOctokit as never, "someuser"); + + expect(result.events).toHaveLength(1); + expect(result.events[0].id).toBe("301"); + expect(result.changed).toBe(true); + }); + + it("uses numeric comparison for event ID filtering (not lexicographic)", async () => { + // Seed with lastEventId = "9" + const firstOctokit = makeOctokit(() => + Promise.resolve({ data: [makeEvent({ id: "9" })], headers: {} }) + ); + await fetchUserEvents(firstOctokit as never, "someuser"); + + // "10" > "9" numerically but NOT lexicographically + const secondOctokit = makeOctokit(() => + Promise.resolve({ + data: [makeEvent({ id: "10" }), makeEvent({ id: "8" })], + headers: {}, + }) + ); + const result = await fetchUserEvents(secondOctokit as never, "someuser"); + + expect(result.events).toHaveLength(1); + expect(result.events[0].id).toBe("10"); + }); + + it("returns changed=false when no new events since last ID", async () => { + // First call: seed lastEventId = "500" + const firstOctokit = makeOctokit(() => + Promise.resolve({ data: [makeEvent({ id: "500" })], headers: {} }) + ); + await fetchUserEvents(firstOctokit as never, "someuser"); + + // Second call: no new events (all IDs <= 500) + const secondOctokit = makeOctokit(() => + Promise.resolve({ + data: [makeEvent({ id: "500" }), makeEvent({ id: "499" })], + headers: {}, + }) + ); + const result = await fetchUserEvents(secondOctokit as never, "someuser"); + + expect(result.changed).toBe(false); + expect(result.events).toHaveLength(0); + }); + + it("returns empty events and changed=false for empty username (SEC-IMPL-001)", async () => { + const octokit = makeOctokit(() => Promise.resolve({ data: [], headers: {} })); + + const result = await fetchUserEvents(octokit as never, ""); + + expect(result.changed).toBe(false); + expect(result.events).toHaveLength(0); + expect(octokit.request).not.toHaveBeenCalled(); + }); +}); + +// ── parseRepoEvents ─────────────────────────────────────────────────────────── + +describe("parseRepoEvents", () => { + it("returns empty map for empty events array", () => { + const result = parseRepoEvents([], new Set(["owner/repo"])); + expect(result.size).toBe(0); + }); + + it("filters out events for untracked repos", () => { + const events = [ + makeEvent({ type: "IssuesEvent", repoName: "owner/tracked" }), + makeEvent({ type: "IssuesEvent", repoName: "owner/untracked" }), + ]; + const result = parseRepoEvents(events, new Set(["owner/tracked"])); + + expect(result.size).toBe(1); + expect([...result.keys()]).toContain("owner/tracked"); + }); + + it("filters out non-actionable event types", () => { + const events = [ + makeEvent({ type: "CreateEvent", repoName: "owner/repo" }), + makeEvent({ type: "DeleteEvent", repoName: "owner/repo" }), + makeEvent({ type: "WatchEvent", repoName: "owner/repo" }), + ]; + const result = parseRepoEvents(events, new Set(["owner/repo"])); + + expect(result.size).toBe(0); + }); + + it("sets hasIssueActivity for IssuesEvent and IssueCommentEvent", () => { + const events = [ + makeEvent({ type: "IssuesEvent", repoName: "owner/repo" }), + makeEvent({ type: "IssueCommentEvent", repoName: "owner/repo" }), + ]; + const result = parseRepoEvents(events, new Set(["owner/repo"])); + const summary = result.get("owner/repo")!; + + expect(summary.hasIssueActivity).toBe(true); + expect(summary.hasPRActivity).toBe(false); + expect(summary.hasWorkflowActivity).toBe(false); + }); + + it("sets hasPRActivity for PullRequestEvent, PullRequestReviewEvent, PullRequestReviewCommentEvent", () => { + const events = [ + makeEvent({ type: "PullRequestEvent", repoName: "owner/repo" }), + makeEvent({ type: "PullRequestReviewEvent", repoName: "owner/repo" }), + makeEvent({ type: "PullRequestReviewCommentEvent", repoName: "owner/repo" }), + ]; + const result = parseRepoEvents(events, new Set(["owner/repo"])); + const summary = result.get("owner/repo")!; + + expect(summary.hasPRActivity).toBe(true); + expect(summary.hasIssueActivity).toBe(false); + }); + + it("sets hasWorkflowActivity for PushEvent", () => { + const events = [makeEvent({ type: "PushEvent", repoName: "owner/repo" })]; + const result = parseRepoEvents(events, new Set(["owner/repo"])); + + expect(result.get("owner/repo")!.hasWorkflowActivity).toBe(true); + }); + + it("does case-insensitive repo matching: Owner/Repo vs owner/repo", () => { + const events = [ + makeEvent({ type: "IssuesEvent", repoName: "Owner/Repo" }), + ]; + const result = parseRepoEvents(events, new Set(["owner/repo"])); + + expect(result.size).toBe(1); + }); + + it("picks the max timestamp for latestEventAt", () => { + const events = [ + makeEvent({ type: "IssuesEvent", repoName: "owner/repo", created_at: "2026-01-01T10:00:00Z" }), + makeEvent({ type: "PushEvent", repoName: "owner/repo", created_at: "2026-01-01T12:00:00Z" }), + makeEvent({ type: "PullRequestEvent", repoName: "owner/repo", created_at: "2026-01-01T08:00:00Z" }), + ]; + const result = parseRepoEvents(events, new Set(["owner/repo"])); + + expect(result.get("owner/repo")!.latestEventAt).toBe("2026-01-01T12:00:00Z"); + }); + + it("groups multiple events for the same repo into one summary", () => { + const events = [ + makeEvent({ type: "IssuesEvent", repoName: "owner/repo" }), + makeEvent({ type: "PushEvent", repoName: "owner/repo" }), + ]; + const result = parseRepoEvents(events, new Set(["owner/repo"])); + + expect(result.size).toBe(1); + const summary = result.get("owner/repo")!; + expect(summary.hasIssueActivity).toBe(true); + expect(summary.hasWorkflowActivity).toBe(true); + expect(summary.eventTypes.size).toBe(2); + }); + + it("handles mix of event types across tracked and untracked repos", () => { + const events = [ + makeEvent({ type: "IssuesEvent", repoName: "owner/a" }), + makeEvent({ type: "PushEvent", repoName: "owner/b" }), + makeEvent({ type: "PullRequestEvent", repoName: "owner/c" }), // untracked + makeEvent({ type: "CreateEvent", repoName: "owner/a" }), // non-actionable + ]; + const result = parseRepoEvents(events, new Set(["owner/a", "owner/b"])); + + expect(result.size).toBe(2); + expect(result.get("owner/a")!.hasIssueActivity).toBe(true); + expect(result.get("owner/b")!.hasWorkflowActivity).toBe(true); + }); +}); + +// ── resetEventsState ────────────────────────────────────────────────────────── + +describe("resetEventsState", () => { + it("clears ETag so next call sends no If-None-Match header", async () => { + const octokit = makeOctokit(() => + Promise.resolve({ + data: [makeEvent({ id: "100" })], + headers: { etag: '"etag-123"' }, + }) + ); + + // First call — seeds ETag + await fetchUserEvents(octokit as never, "someuser"); + + // Reset + resetEventsState(); + + // Next call should have no If-None-Match + await fetchUserEvents(octokit as never, "someuser"); + + const thirdCallHeaders = (octokit.request.mock.calls[1][1] as { headers?: Record }).headers ?? {}; + expect(thirdCallHeaders["If-None-Match"]).toBeUndefined(); + }); + + it("clears lastEventId so next call returns all events (first-call semantics)", async () => { + // First call: seed lastEventId = "100" + const firstOctokit = makeOctokit(() => + Promise.resolve({ data: [makeEvent({ id: "100" })], headers: {} }) + ); + await fetchUserEvents(firstOctokit as never, "someuser"); + + // Reset + resetEventsState(); + + // After reset, next call should behave like first call (return all events, not filter) + const secondOctokit = makeOctokit(() => + Promise.resolve({ + data: [makeEvent({ id: "100" }), makeEvent({ id: "99" })], + headers: {}, + }) + ); + const result = await fetchUserEvents(secondOctokit as never, "someuser"); + + // All events returned — no ID filtering since _lastEventId was cleared + expect(result.events).toHaveLength(2); + expect(result.changed).toBe(true); + }); +}); diff --git a/tests/services/jira-client.test.ts b/tests/services/jira-client.test.ts new file mode 100644 index 00000000..d77a4ab9 --- /dev/null +++ b/tests/services/jira-client.test.ts @@ -0,0 +1,585 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + JiraClient, + JiraProxyClient, + JiraApiError, + JiraRateLimitError, +} from "../../src/app/services/jira-client"; +import type { JiraIssue, JiraBulkFetchResult, JiraSearchResult, JiraAccessibleResource } from "../../src/shared/jira-types"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeIssue(key = "PROJ-1"): JiraIssue { + return { + id: "10001", + key, + self: `https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/issue/${key}`, + fields: { + summary: "Test issue summary", + status: { + id: "1", + name: "In Progress", + statusCategory: { id: 4, key: "indeterminate", name: "In Progress" }, + }, + priority: { id: "2", name: "High" }, + assignee: { accountId: "abc123", displayName: "Test User" }, + project: { id: "10000", key: "PROJ", name: "My Project" }, + updated: "2026-04-24T12:00:00.000+0000", + }, + }; +} + +function makeAccessibleResource(id = "cloud-abc"): JiraAccessibleResource { + return { + id, + name: "My Jira Site", + url: "https://mysite.atlassian.net", + scopes: ["read:jira-work", "read:jira-user"], + }; +} + +// ── JiraClient (OAuth / Bearer) ─────────────────────────────────────────────── + +describe("JiraClient", () => { + const cloudId = "test-cloud-id"; + const accessToken = "test-access-token"; + let client: JiraClient; + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + client = new JiraClient(cloudId, async () => accessToken); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── getIssue ─────────────────────────────────────────────────────────────── + + describe("getIssue", () => { + it("constructs correct URL with default fields", async () => { + const issue = makeIssue("PROJ-42"); + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(issue), { status: 200 }) + ); + + await client.getIssue("PROJ-42"); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/PROJ-42?fields=summary,status,priority,assignee,project,updated,issuetype,created` + ); + }); + + it("constructs correct URL with custom fields", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(makeIssue()), { status: 200 }) + ); + + await client.getIssue("PROJ-1", ["summary", "status"]); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("fields=summary,status"); + }); + + it("adds Bearer Authorization header", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(makeIssue()), { status: 200 }) + ); + + await client.getIssue("PROJ-1"); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["Authorization"]).toBe(`Bearer ${accessToken}`); + }); + + it("returns null on 404", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errorMessages: ["Issue does not exist"] }), { status: 404 }) + ); + + const result = await client.getIssue("MISSING-1"); + expect(result).toBeNull(); + }); + + it("returns the issue on success", async () => { + const issue = makeIssue("PROJ-7"); + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(issue), { status: 200 }) + ); + + const result = await client.getIssue("PROJ-7"); + expect(result?.key).toBe("PROJ-7"); + }); + + it("throws JiraApiError on non-404 HTTP error", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errorMessages: ["Forbidden"] }), { status: 403 }) + ); + + await expect(client.getIssue("PROJ-1")).rejects.toThrow(JiraApiError); + }); + + it("propagates JiraRateLimitError from request when rate-limited", async () => { + const headers = new Headers({ "Retry-After": "30" }); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 429, headers })); + + await expect(client.getIssue("PROJ-1")).rejects.toThrow(JiraRateLimitError); + }); + }); + + // ── searchJql ───────────────────────────────────────────────────────────── + + describe("searchJql", () => { + it("constructs correct query params", async () => { + const result: JiraSearchResult = { issues: [], total: 0, maxResults: 50, startAt: 0 }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(result), { status: 200 })); + + await client.searchJql("assignee = currentUser()"); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toContain("/search/jql"); + expect(parsed.searchParams.get("jql")).toBe("assignee = currentUser()"); + expect(parsed.searchParams.get("maxResults")).toBe("100"); + expect(parsed.searchParams.get("startAt")).toBe("0"); + expect(parsed.searchParams.get("fields")).toContain("summary"); + }); + + it("respects custom opts (maxResults, startAt, fields)", async () => { + const result: JiraSearchResult = { issues: [], total: 0, maxResults: 10, startAt: 5 }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(result), { status: 200 })); + + await client.searchJql("project = PROJ", { maxResults: 10, startAt: 5, fields: ["summary"] }); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.searchParams.get("maxResults")).toBe("10"); + expect(parsed.searchParams.get("startAt")).toBe("5"); + expect(parsed.searchParams.get("fields")).toBe("summary"); + }); + + it("adds Bearer header", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 100, startAt: 0 }), { status: 200 }) + ); + + await client.searchJql("project = TEST"); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["Authorization"]).toBe(`Bearer ${accessToken}`); + }); + + it("throws JiraApiError on non-ok response", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errorMessages: ["Bad request"] }), { status: 400 }) + ); + + await expect(client.searchJql("invalid jql")).rejects.toThrow(JiraApiError); + }); + + it("JiraApiError carries status and body", async () => { + const body = { errorMessages: ["Some error"] }; + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(body), { status: 400 }) + ); + + let caught: JiraApiError | null = null; + try { + await client.searchJql("bad"); + } catch (e) { + caught = e as JiraApiError; + } + + expect(caught).toBeInstanceOf(JiraApiError); + expect(caught?.status).toBe(400); + expect(caught?.body).toEqual(body); + }); + }); + + // ── bulkFetch ───────────────────────────────────────────────────────────── + + describe("bulkFetch", () => { + it("sends POST to correct endpoint with JSON body", async () => { + const result: JiraBulkFetchResult = { issues: [makeIssue("PROJ-1"), makeIssue("PROJ-2")] }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(result), { status: 200 })); + + await client.bulkFetch(["PROJ-1", "PROJ-2"]); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/issue/bulkfetch"); + expect(init.method).toBe("POST"); + const bodyParsed = JSON.parse(init.body as string); + expect(bodyParsed.issueIdsOrKeys).toEqual(["PROJ-1", "PROJ-2"]); + expect(bodyParsed.fields).toContain("summary"); + }); + + it("sends custom fields when provided", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [] }), { status: 200 }) + ); + + await client.bulkFetch(["PROJ-1"], ["summary"]); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const bodyParsed = JSON.parse(init.body as string); + expect(bodyParsed.fields).toEqual(["summary"]); + }); + + it("adds Bearer header", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [] }), { status: 200 }) + ); + + await client.bulkFetch(["PROJ-1"]); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["Authorization"]).toBe(`Bearer ${accessToken}`); + }); + }); + + // ── 429 rate limit ──────────────────────────────────────────────────────── + + describe("429 rate limit handling", () => { + it("throws JiraRateLimitError with retryAfterSeconds from header", async () => { + const headers = new Headers({ "Retry-After": "45" }); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 429, headers })); + + let caught: JiraRateLimitError | null = null; + try { + await client.searchJql("project = PROJ"); + } catch (e) { + caught = e as JiraRateLimitError; + } + + expect(caught).toBeInstanceOf(JiraRateLimitError); + expect(caught?.retryAfterSeconds).toBe(45); + }); + + it("defaults retryAfterSeconds to 60 when Retry-After header is absent", async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 429 })); + + let caught: JiraRateLimitError | null = null; + try { + await client.searchJql("project = PROJ"); + } catch (e) { + caught = e as JiraRateLimitError; + } + + expect(caught).toBeInstanceOf(JiraRateLimitError); + expect(caught?.retryAfterSeconds).toBe(60); + }); + + it("defaults retryAfterSeconds to 60 when Retry-After is non-numeric", async () => { + const headers = new Headers({ "Retry-After": "invalid" }); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 429, headers })); + + let caught: JiraRateLimitError | null = null; + try { + await client.getIssue("PROJ-1"); + } catch (e) { + caught = e as JiraRateLimitError; + } + + expect(caught).toBeInstanceOf(JiraRateLimitError); + expect(caught?.retryAfterSeconds).toBe(60); + }); + }); + + // ── getAccessibleResources ───────────────────────────────────────────────── + + describe("getAccessibleResources", () => { + it("calls the accessible-resources endpoint with Bearer header", async () => { + const resources = [makeAccessibleResource("cloud-xyz")]; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(resources), { status: 200 })); + + const result = await JiraClient.getAccessibleResources("token-abc"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.atlassian.com/oauth/token/accessible-resources"); + const headers = init.headers as Record; + expect(headers["Authorization"]).toBe("Bearer token-abc"); + expect(result).toEqual(resources); + }); + + it("throws JiraRateLimitError on 429", async () => { + const headers = new Headers({ "Retry-After": "10" }); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 429, headers })); + + await expect(JiraClient.getAccessibleResources("tok")).rejects.toThrow(JiraRateLimitError); + }); + + it("throws JiraApiError on non-ok response", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Unauthorized" }), { status: 401 }) + ); + + await expect(JiraClient.getAccessibleResources("bad-token")).rejects.toThrow(JiraApiError); + }); + }); +}); + +// ── JiraProxyClient (API token / Worker proxy) ──────────────────────────────── + +describe("JiraProxyClient", () => { + const cloudId = "proxy-cloud-id"; + const email = "user@example.com"; + const sealed = "sealed-api-token-blob"; + let client: JiraProxyClient; + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + client = new JiraProxyClient(cloudId, email, sealed); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── getIssue via bulkFetch ───────────────────────────────────────────────── + + describe("getIssue", () => { + it("routes through /api/jira/proxy with correct body shape", async () => { + const result: JiraBulkFetchResult = { issues: [makeIssue("PROJ-1")] }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(result), { status: 200 })); + + await client.getIssue("PROJ-1"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/jira/proxy"); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body as string); + expect(body.endpoint).toBe("issue"); + expect(body.cloudId).toBe(cloudId); + expect(body.email).toBe(email); + expect(body.sealed).toBe(sealed); + expect(body.params.issueIdsOrKeys).toEqual(["PROJ-1"]); + }); + + it("returns the issue when bulkFetch contains the key", async () => { + const issue = makeIssue("PROJ-5"); + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [issue] }), { status: 200 }) + ); + + const result = await client.getIssue("PROJ-5"); + expect(result?.key).toBe("PROJ-5"); + }); + + it("returns null when issues array is empty", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [] }), { status: 200 }) + ); + + const result = await client.getIssue("MISSING-1"); + expect(result).toBeNull(); + }); + + it("returns null when key appears in errors array", async () => { + const result: JiraBulkFetchResult = { + issues: [], + errors: [{ issueIdsOrKeys: ["MISSING-1"], status: 404 }], + }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(result), { status: 200 })); + + const found = await client.getIssue("MISSING-1"); + expect(found).toBeNull(); + }); + + it("returns null when key is in errors even if issues array has other results", async () => { + const result: JiraBulkFetchResult = { + issues: [makeIssue("PROJ-2")], + errors: [{ issueIdsOrKeys: ["MISSING-1"], status: 404 }], + }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(result), { status: 200 })); + + const found = await client.getIssue("MISSING-1"); + expect(found).toBeNull(); + }); + + it("returns null when bulkFetch returns an issue with a different key", async () => { + const result: JiraBulkFetchResult = { issues: [makeIssue("PROJ-OTHER")] }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(result), { status: 200 })); + + const found = await client.getIssue("PROJ-1"); + expect(found).toBeNull(); + }); + }); + + // ── searchJql ───────────────────────────────────────────────────────────── + + describe("searchJql", () => { + it("routes through /api/jira/proxy with endpoint=search", async () => { + const searchResult: JiraSearchResult = { issues: [], total: 0, maxResults: 100, startAt: 0 }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(searchResult), { status: 200 })); + + await client.searchJql("assignee = currentUser()"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/jira/proxy"); + const body = JSON.parse(init.body as string); + expect(body.endpoint).toBe("search"); + expect(body.params.jql).toBe("assignee = currentUser()"); + expect(body.params.maxResults).toBe(100); + }); + + it("passes custom opts through params", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 10, startAt: 20 }), { status: 200 }) + ); + + await client.searchJql("project = X", { maxResults: 10, startAt: 20, fields: ["summary"] }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.params.maxResults).toBe(10); + expect(body.params.startAt).toBe(20); + expect(body.params.fields).toEqual(["summary"]); + }); + + it("includes X-Requested-With header", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 100, startAt: 0 }), { status: 200 }) + ); + + await client.searchJql("project = TEST"); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Requested-With"]).toBe("fetch"); + }); + + it("calls onResealed callback when response includes resealed field", async () => { + const onResealed = vi.fn(); + const clientWithCallback = new JiraProxyClient(cloudId, email, sealed, onResealed); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ issues: [], total: 0, maxResults: 100, startAt: 0, resealed: "new-sealed-token" }), + { status: 200 } + ) + ); + + const result = await clientWithCallback.searchJql("project = TEST"); + + expect(onResealed).toHaveBeenCalledOnce(); + expect(onResealed).toHaveBeenCalledWith("new-sealed-token"); + expect("resealed" in result).toBe(false); + }); + + it("does not call onResealed when resealed is absent", async () => { + const onResealed = vi.fn(); + const clientWithCallback = new JiraProxyClient(cloudId, email, sealed, onResealed); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ issues: [], total: 0, maxResults: 100, startAt: 0 }), + { status: 200 } + ) + ); + + await clientWithCallback.searchJql("project = TEST"); + + expect(onResealed).not.toHaveBeenCalled(); + }); + }); + + // ── bulkFetch ───────────────────────────────────────────────────────────── + + describe("bulkFetch", () => { + it("sends endpoint=issue with issueIdsOrKeys array", async () => { + const bulkResult: JiraBulkFetchResult = { + issues: [makeIssue("PROJ-1"), makeIssue("PROJ-2")], + }; + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(bulkResult), { status: 200 })); + + await client.bulkFetch(["PROJ-1", "PROJ-2"]); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.endpoint).toBe("issue"); + expect(body.params.issueIdsOrKeys).toEqual(["PROJ-1", "PROJ-2"]); + }); + + it("calls onResealed callback when response includes resealed field", async () => { + const onResealed = vi.fn(); + const clientWithCallback = new JiraProxyClient(cloudId, email, sealed, onResealed); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ issues: [], resealed: "new-sealed-token" }), + { status: 200 } + ) + ); + + await clientWithCallback.bulkFetch(["PROJ-1"]); + + expect(onResealed).toHaveBeenCalledOnce(); + expect(onResealed).toHaveBeenCalledWith("new-sealed-token"); + }); + + it("does not call onResealed when resealed is absent", async () => { + const onResealed = vi.fn(); + const clientWithCallback = new JiraProxyClient(cloudId, email, sealed, onResealed); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ issues: [] }), + { status: 200 } + ) + ); + + await clientWithCallback.bulkFetch(["PROJ-1"]); + + expect(onResealed).not.toHaveBeenCalled(); + }); + }); + + // ── 429 via proxy ───────────────────────────────────────────────────────── + + describe("429 handling via proxy", () => { + it("throws JiraRateLimitError with retryAfterSeconds", async () => { + const headers = new Headers({ "Retry-After": "20" }); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 429, headers })); + + let caught: JiraRateLimitError | null = null; + try { + await client.searchJql("assignee = me"); + } catch (e) { + caught = e as JiraRateLimitError; + } + + expect(caught).toBeInstanceOf(JiraRateLimitError); + expect(caught?.retryAfterSeconds).toBe(20); + }); + + it("throws JiraApiError on non-ok proxy response", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: "bad_request" }), { status: 400 }) + ); + + await expect(client.searchJql("bad jql")).rejects.toThrow(JiraApiError); + }); + + it("JiraApiError carries status and body from proxy response", async () => { + const body = { error: "unauthorized", message: "Invalid credentials" }; + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(body), { status: 401 }) + ); + + let caught: JiraApiError | null = null; + try { + await client.searchJql("project = X"); + } catch (e) { + caught = e as JiraApiError; + } + + expect(caught).toBeInstanceOf(JiraApiError); + expect(caught?.status).toBe(401); + expect(caught?.body).toEqual(body); + }); + }); +}); diff --git a/tests/services/jira-keys.test.ts b/tests/services/jira-keys.test.ts new file mode 100644 index 00000000..1843beec --- /dev/null +++ b/tests/services/jira-keys.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { IJiraClient } from "../../src/app/services/jira-client"; +import { JiraApiError } from "../../src/app/services/jira-client"; +import type { JiraIssue, JiraBulkFetchResult } from "../../src/shared/jira-types"; + +// ── Module under test (imported after mock setup) ───────────────────────────── +// We import after each reset so the module-level cache is fresh. + +function makeIssue(key = "PROJ-1"): JiraIssue { + return { + id: "10001", + key, + self: `https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/issue/${key}`, + fields: { + summary: `Summary for ${key}`, + status: { + id: "1", + name: "In Progress", + statusCategory: { id: 4, key: "indeterminate", name: "In Progress" }, + }, + priority: { id: "2", name: "High" }, + assignee: { accountId: "abc123", displayName: "Test User" }, + project: { id: "10000", key: "PROJ", name: "My Project" }, + }, + }; +} + +function makeBulkResult(issues: JiraIssue[], errorKeys: string[] = []): JiraBulkFetchResult { + return { + issues, + errors: errorKeys.length > 0 ? [{ issueIdsOrKeys: errorKeys, status: 404 }] : [], + }; +} + +function makeClient(overrides: Partial = {}): IJiraClient { + return { + getIssue: vi.fn().mockResolvedValue(null), + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([])), + searchJql: vi.fn().mockResolvedValue({ issues: [], total: 0, maxResults: 50, startAt: 0 }), + ...overrides, + }; +} + +// ── lookupKeys ──────────────────────────────────────────────────────────────── + +describe("lookupKeys", () => { + let lookupKeys: (typeof import("../../src/app/services/jira-keys"))["lookupKeys"]; + let clearJiraKeyCache: (typeof import("../../src/app/services/jira-keys"))["clearJiraKeyCache"]; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../../src/app/services/jira-keys"); + lookupKeys = mod.lookupKeys; + clearJiraKeyCache = mod.clearJiraKeyCache; + clearJiraKeyCache(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns empty map when called with empty keys array", async () => { + const client = makeClient(); + const result = await lookupKeys([], client); + expect(result.size).toBe(0); + expect(client.bulkFetch).not.toHaveBeenCalled(); + }); + + it("calls bulkFetch for uncached keys", async () => { + const issue = makeIssue("PROJ-1"); + const client = makeClient({ + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([issue])), + }); + + const result = await lookupKeys(["PROJ-1"], client); + + expect(client.bulkFetch).toHaveBeenCalledWith(["PROJ-1"]); + expect(result.get("PROJ-1")).toEqual(issue); + }); + + it("returns cached result on second call without re-fetching", async () => { + const issue = makeIssue("PROJ-2"); + const client = makeClient({ + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([issue])), + }); + + await lookupKeys(["PROJ-2"], client); + const result = await lookupKeys(["PROJ-2"], client); + + // bulkFetch called once only — second call hits cache + expect(client.bulkFetch).toHaveBeenCalledTimes(1); + expect(result.get("PROJ-2")).toEqual(issue); + }); + + it("caches null for keys returned in errors array", async () => { + const client = makeClient({ + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([], ["PROJ-99"])), + }); + + const result = await lookupKeys(["PROJ-99"], client); + + expect(result.get("PROJ-99")).toBeNull(); + }); + + it("caches null for keys not in result or errors (unknown key)", async () => { + const client = makeClient({ + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([])), + }); + + const result = await lookupKeys(["UNKN-1"], client); + + expect(result.get("UNKN-1")).toBeNull(); + }); + + it("caches null for all keys in batch on JiraApiError", async () => { + const client = makeClient({ + bulkFetch: vi.fn().mockRejectedValue(new JiraApiError(403, null, "Forbidden")), + }); + + const result = await lookupKeys(["PROJ-1", "PROJ-2"], client); + + expect(result.get("PROJ-1")).toBeNull(); + expect(result.get("PROJ-2")).toBeNull(); + }); + + it("falls back to getIssue per-key on CORS/network error (non-JiraApiError)", async () => { + const issue = makeIssue("PROJ-5"); + const client = makeClient({ + bulkFetch: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")), + getIssue: vi.fn().mockResolvedValue(issue), + }); + + const result = await lookupKeys(["PROJ-5"], client); + + expect(client.getIssue).toHaveBeenCalledWith("PROJ-5"); + expect(result.get("PROJ-5")).toEqual(issue); + }); + + it("only fetches uncached keys (mix of cached and uncached)", async () => { + const issue1 = makeIssue("PROJ-1"); + const issue2 = makeIssue("PROJ-2"); + const client = makeClient({ + bulkFetch: vi.fn() + .mockResolvedValueOnce(makeBulkResult([issue1])) + .mockResolvedValueOnce(makeBulkResult([issue2])), + }); + + await lookupKeys(["PROJ-1"], client); + const result = await lookupKeys(["PROJ-1", "PROJ-2"], client); + + // Only PROJ-2 fetched in second call + expect(vi.mocked(client.bulkFetch).mock.calls[1]).toEqual([["PROJ-2"]]); + expect(result.get("PROJ-1")).toEqual(issue1); + expect(result.get("PROJ-2")).toEqual(issue2); + }); +}); + +// ── clearJiraKeyCache ───────────────────────────────────────────────────────── + +describe("clearJiraKeyCache", () => { + let lookupKeys: (typeof import("../../src/app/services/jira-keys"))["lookupKeys"]; + let clearJiraKeyCache: (typeof import("../../src/app/services/jira-keys"))["clearJiraKeyCache"]; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../../src/app/services/jira-keys"); + lookupKeys = mod.lookupKeys; + clearJiraKeyCache = mod.clearJiraKeyCache; + clearJiraKeyCache(); + }); + + it("evicts all cached entries so next call fetches again", async () => { + const issue = makeIssue("PROJ-1"); + const client = makeClient({ + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([issue])), + }); + + await lookupKeys(["PROJ-1"], client); + clearJiraKeyCache(); + await lookupKeys(["PROJ-1"], client); + + expect(client.bulkFetch).toHaveBeenCalledTimes(2); + }); +}); + +// ── detectAndLookupJiraKeys ─────────────────────────────────────────────────── + +describe("detectAndLookupJiraKeys", () => { + let detectAndLookupJiraKeys: (typeof import("../../src/app/services/jira-keys"))["detectAndLookupJiraKeys"]; + let clearJiraKeyCache: (typeof import("../../src/app/services/jira-keys"))["clearJiraKeyCache"]; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../../src/app/services/jira-keys"); + detectAndLookupJiraKeys = mod.detectAndLookupJiraKeys; + clearJiraKeyCache = mod.clearJiraKeyCache; + clearJiraKeyCache(); + }); + + it("extracts Jira keys from issue titles", async () => { + const issue = makeIssue("PROJ-123"); + const client = makeClient({ + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([issue])), + }); + + await detectAndLookupJiraKeys( + [{ title: "Fix PROJ-123: null pointer exception" }], + client + ); + + expect(client.bulkFetch).toHaveBeenCalledWith(["PROJ-123"]); + }); + + it("extracts Jira keys from PR headRef (branch names)", async () => { + const issue = makeIssue("FEAT-42"); + const client = makeClient({ + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([issue])), + }); + + await detectAndLookupJiraKeys( + [{ title: "Update README", headRef: "feat/FEAT-42-add-widget" }], + client + ); + + const calls = vi.mocked(client.bulkFetch).mock.calls; + expect(calls.some((c) => (c[0] as string[]).includes("FEAT-42"))).toBe(true); + }); + + it("deduplicates keys found in multiple items", async () => { + const client = makeClient({ + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([])), + }); + + await detectAndLookupJiraKeys( + [ + { title: "Fix PROJ-1" }, + { title: "Also fixes PROJ-1" }, + ], + client + ); + + // Should only call bulkFetch once with PROJ-1 (not duplicated) + const keys = vi.mocked(client.bulkFetch).mock.calls[0]?.[0] as string[]; + expect(keys.filter((k) => k === "PROJ-1")).toHaveLength(1); + }); + + it("returns empty map when no Jira keys are found in titles", async () => { + const client = makeClient({ + bulkFetch: vi.fn().mockResolvedValue(makeBulkResult([])), + }); + + const result = await detectAndLookupJiraKeys( + [{ title: "No jira key here" }, { title: "Just a plain PR" }], + client + ); + + expect(client.bulkFetch).not.toHaveBeenCalled(); + expect(result.size).toBe(0); + }); +}); + +// ── Cache cap at 500 ────────────────────────────────────────────────────────── + +describe("cache cap at 500", () => { + let lookupKeys: (typeof import("../../src/app/services/jira-keys"))["lookupKeys"]; + let clearJiraKeyCache: (typeof import("../../src/app/services/jira-keys"))["clearJiraKeyCache"]; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../../src/app/services/jira-keys"); + lookupKeys = mod.lookupKeys; + clearJiraKeyCache = mod.clearJiraKeyCache; + clearJiraKeyCache(); + }); + + it("evicts oldest entry when cache reaches cap (500)", async () => { + // Fill cache with exactly 500 unique keys (KEY-A-1 ... KEY-A-500) + const keys500 = Array.from({ length: 500 }, (_, i) => `KEY-A-${i + 1}`); + const client = makeClient({ + bulkFetch: vi.fn().mockImplementation((ks: string[]) => + Promise.resolve(makeBulkResult(ks.map(makeIssue))) + ), + }); + + // Batch-fill all 500 keys at once — cache is now exactly at cap + await lookupKeys(keys500, client); + + // Adding a 501st unique key triggers eviction of KEY-A-1 (oldest) + vi.mocked(client.bulkFetch).mockClear(); + await lookupKeys(["KEY-A-501"], client); + + // Now KEY-A-1 should have been evicted — requesting it again calls bulkFetch + vi.mocked(client.bulkFetch).mockClear(); + await lookupKeys(["KEY-A-1"], client); + + // KEY-A-1 was evicted, so bulkFetch called again + expect(client.bulkFetch).toHaveBeenCalledWith(["KEY-A-1"]); + }, 10000); +}); diff --git a/tests/services/poll-fetchAllData.test.ts b/tests/services/poll-fetchAllData.test.ts index 524be7c1..b754c6fe 100644 --- a/tests/services/poll-fetchAllData.test.ts +++ b/tests/services/poll-fetchAllData.test.ts @@ -73,11 +73,11 @@ afterEach(() => { vi.clearAllMocks(); }); -// ── qa-1: First call returns data and updates _lastSuccessfulFetch ──────────── +// ── qa-1: fetchAllData returns data ────────────────────────────────────────── describe("fetchAllData — first call", () => { - it("returns data from all fetches on first call", async () => { + it("returns data from all fetches", async () => { vi.resetModules(); const { getClient } = await import("../../src/app/services/github"); @@ -96,10 +96,9 @@ describe("fetchAllData — first call", () => { expect(result.pullRequests).toEqual([]); expect(result.workflowRuns).toEqual([]); expect(result.errors).toEqual([]); - expect(result.skipped).toBeUndefined(); }); - it("calls both fetch functions on first call (no notification gate)", async () => { + it("calls both fetch functions unconditionally on every call", async () => { vi.resetModules(); const { getClient } = await import("../../src/app/services/github"); @@ -114,9 +113,16 @@ describe("fetchAllData — first call", () => { await fetchAllData(); - // First call: no _lastSuccessfulFetch, so notifications gate is skipped + // No notification gate — both data fetches always run + expect(mockOctokit.request).not.toHaveBeenCalled(); + expect(fetchIssuesAndPullRequests).toHaveBeenCalledOnce(); + expect(fetchWorkflowRuns).toHaveBeenCalledOnce(); + + // Second call — still unconditional, no gate check + vi.mocked(fetchIssuesAndPullRequests).mockClear(); + vi.mocked(fetchWorkflowRuns).mockClear(); + await fetchAllData(); expect(mockOctokit.request).not.toHaveBeenCalled(); - // Both data fetches should run expect(fetchIssuesAndPullRequests).toHaveBeenCalledOnce(); expect(fetchWorkflowRuns).toHaveBeenCalledOnce(); }); @@ -145,111 +151,10 @@ describe("fetchAllData — first call", () => { config.maxRunsPerWorkflow ); }); - - it("sets _lastSuccessfulFetch so second call checks notification gate", async () => { - vi.resetModules(); - - const { getClient } = await import("../../src/app/services/github"); - const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); - const mockOctokit = makeMockOctokit(); - vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - - - const { fetchAllData } = await import("../../src/app/services/poll"); - - // First call — no gate check - await fetchAllData(); - expect(mockOctokit.request).not.toHaveBeenCalled(); - - // Second call — _lastSuccessfulFetch is set, gate checks notifications - // Return 200 for notifications (something changed) - mockOctokit.request.mockResolvedValueOnce({ - data: [], - headers: { "last-modified": "Thu, 20 Mar 2026 12:00:00 GMT" }, - }); - - await fetchAllData(); - - expect(mockOctokit.request).toHaveBeenCalledOnce(); - expect(mockOctokit.request).toHaveBeenCalledWith( - "GET /notifications", - expect.objectContaining({ per_page: 1 }) - ); - }); }); -// ── qa-1: Notification gate skips full fetch when nothing changed ───────────── - -describe("fetchAllData — notification gate skip", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("returns { skipped: true } when hasNotificationChanges returns false (304)", async () => { - vi.resetModules(); - - const { getClient } = await import("../../src/app/services/github"); - const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); - const mockOctokit = makeMockOctokit(); - vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - - - const { fetchAllData } = await import("../../src/app/services/poll"); - - // First call to set _lastSuccessfulFetch - await fetchAllData(); - - vi.mocked(fetchIssuesAndPullRequests).mockClear(); - vi.mocked(fetchWorkflowRuns).mockClear(); - - // Simulate 304 from notifications — nothing changed - mockOctokit.request.mockRejectedValueOnce({ status: 304 }); - - const result = await fetchAllData(); - - expect(result.skipped).toBe(true); - // Data fetches should NOT have been called - expect(fetchIssuesAndPullRequests).not.toHaveBeenCalled(); - expect(fetchWorkflowRuns).not.toHaveBeenCalled(); - }); - - it("forces full fetch when staleness exceeds 10 minutes even if gate would skip", async () => { - vi.useFakeTimers(); - vi.resetModules(); - - const { getClient } = await import("../../src/app/services/github"); - const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); - const mockOctokit = makeMockOctokit(); - vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - - const { fetchAllData } = await import("../../src/app/services/poll"); - - // First call — sets _lastSuccessfulFetch - await fetchAllData(); - vi.mocked(fetchIssuesAndPullRequests).mockClear(); - - // Advance time past 10 minutes - vi.advanceTimersByTime(11 * 60 * 1000); - - // Even though notifications would 304, staleness cap forces a full fetch - mockOctokit.request.mockRejectedValueOnce({ status: 304 }); - - const result = await fetchAllData(); - - // Should NOT be skipped — staleness cap bypasses the gate - expect(result.skipped).toBeUndefined(); - expect(fetchIssuesAndPullRequests).toHaveBeenCalled(); - }); -}); - -// ── qa-1: All fetches fail — errors aggregated, _lastSuccessfulFetch not updated ── +// ── All fetches fail — errors aggregated ───────────────────────────────────── describe("fetchAllData — all fetches fail", () => { it("aggregates top-level errors when all fetches reject", async () => { @@ -275,10 +180,9 @@ describe("fetchAllData — all fetches fail", () => { expect(result.issues).toEqual([]); expect(result.pullRequests).toEqual([]); expect(result.workflowRuns).toEqual([]); - expect(result.skipped).toBeUndefined(); }); - it("does NOT update _lastSuccessfulFetch when all fetches reject", async () => { + it("fetches are still attempted on subsequent calls even after all fail", async () => { vi.resetModules(); const { getClient } = await import("../../src/app/services/github"); @@ -291,20 +195,18 @@ describe("fetchAllData — all fetches fail", () => { const { fetchAllData } = await import("../../src/app/services/poll"); - // First call — all fail, so _lastSuccessfulFetch should NOT be set await fetchAllData(); - // Second call — if _lastSuccessfulFetch were set, a notification request would be made - // Since all failed, it should NOT be set → no notification request - mockOctokit.request.mockClear(); + // Second call — fetches run again (no gate to suppress them) + vi.mocked(fetchIssuesAndPullRequests).mockClear(); + vi.mocked(fetchWorkflowRuns).mockClear(); vi.mocked(fetchIssuesAndPullRequests).mockRejectedValue(new Error("fail")); vi.mocked(fetchWorkflowRuns).mockRejectedValue(new Error("fail")); - await fetchAllData(); - // No notification gate check — _lastSuccessfulFetch was never set - expect(mockOctokit.request).not.toHaveBeenCalled(); + expect(fetchIssuesAndPullRequests).toHaveBeenCalled(); + expect(fetchWorkflowRuns).toHaveBeenCalled(); }); }); @@ -320,7 +222,7 @@ describe("fetchAllData — partial success", () => { vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); const issues = [{ - id: 1, number: 1, title: "Issue 1", state: "open", + id: 1, number: 1, title: "Issue 1", state: "OPEN" as const, htmlUrl: "https://github.com/o/r/issues/1", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", userLogin: "octocat", userAvatarUrl: "", labels: [], assigneeLogins: [], @@ -362,234 +264,6 @@ describe("fetchAllData — no client", () => { }); }); -// ── qa-4: resetPollState after logout re-enables notification gate ──────────── - -describe("fetchAllData — resetPollState via onAuthCleared", () => { - it("re-enables notification gate after logout (onAuthCleared callback invocation)", async () => { - vi.resetModules(); - - const { getClient } = await import("../../src/app/services/github"); - const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); - const { onAuthCleared } = await import("../../src/app/stores/auth"); - const mockOctokit = makeMockOctokit(); - vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - - - // Import poll.ts — this triggers onAuthCleared(resetPollState) at module scope. - // api-usage.ts also registers clearUsageData, so onAuthCleared is called multiple times. - const { fetchAllData } = await import("../../src/app/services/poll"); - - // onAuthCleared mock must have been called (multiple registrations expected now). - // Collect all callbacks and invoke them all — mirrors real clearAuth() behavior, - // which fires every registered callback. This avoids fragile positional indexing. - expect(vi.mocked(onAuthCleared)).toHaveBeenCalled(); - const allAuthClearedCallbacks = vi.mocked(onAuthCleared).mock.calls.map((c) => c[0] as () => void); - const capturedAuthClearedCb = () => { for (const cb of allAuthClearedCallbacks) cb(); }; - - // First call — sets _lastSuccessfulFetch - await fetchAllData(); - - // Second call — gate fires a 403, which sets _notifGateDisabled = true - mockOctokit.request.mockRejectedValueOnce({ status: 403 }); - await fetchAllData(); - - // Gate is now disabled; third call should NOT call GET /notifications - mockOctokit.request.mockClear(); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - await fetchAllData(); - expect(mockOctokit.request).not.toHaveBeenCalled(); - - // Invoke the logout callback — resets _notifGateDisabled and _lastSuccessfulFetch - capturedAuthClearedCb(); - - // First call after logout: _lastSuccessfulFetch is null → no gate check - mockOctokit.request.mockClear(); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - await fetchAllData(); - // No notification gate on first call after reset (no _lastSuccessfulFetch) - expect(mockOctokit.request).not.toHaveBeenCalled(); - - // Second call after logout: _lastSuccessfulFetch is now set, gate fires again - mockOctokit.request.mockResolvedValueOnce({ - data: [], - headers: { "last-modified": "Thu, 20 Mar 2026 12:00:00 GMT" }, - }); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - await fetchAllData(); - // GET /notifications was called — gate is active again (not disabled) - expect(mockOctokit.request).toHaveBeenCalledWith( - "GET /notifications", - expect.objectContaining({ per_page: 1 }) - ); - }); -}); - -// ── qa-5: If-Modified-Since header on second notification call ──────────────── - -describe("fetchAllData — If-Modified-Since header", () => { - it("sends If-Modified-Since header from first response on second GET /notifications call", async () => { - vi.resetModules(); - - const { getClient } = await import("../../src/app/services/github"); - const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); - const mockOctokit = makeMockOctokit(); - vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - - - const { fetchAllData } = await import("../../src/app/services/poll"); - - // First call — no gate (no _lastSuccessfulFetch), sets _lastSuccessfulFetch - await fetchAllData(); - - // Second call — gate fires 200 response with last-modified header - const lastModified = "Fri, 21 Mar 2026 08:00:00 GMT"; - mockOctokit.request.mockResolvedValueOnce({ - data: [], - headers: { "last-modified": lastModified }, - }); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - await fetchAllData(); - - // Third call — gate should send If-Modified-Since from the second call's response - mockOctokit.request.mockResolvedValueOnce({ - data: [], - headers: {}, - }); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - await fetchAllData(); - - // Inspect the third GET /notifications call for the If-Modified-Since header - const notifCalls = mockOctokit.request.mock.calls.filter( - (c) => c[0] === "GET /notifications" - ); - expect(notifCalls.length).toBeGreaterThanOrEqual(2); - const thirdCallParams = (notifCalls[notifCalls.length - 1] as unknown[])[1] as Record; - expect((thirdCallParams["headers"] as Record)["If-Modified-Since"]).toBe(lastModified); - }); -}); - -// ── qa-2: hasNotificationChanges 403 auto-disable ──────────────────────────── - -describe("fetchAllData — notification gate 403 auto-disable", () => { - it("disables notification gate after 403 and skips it on subsequent calls", async () => { - vi.resetModules(); - - const { getClient } = await import("../../src/app/services/github"); - const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); - const { pushNotification } = await import("../../src/app/lib/errors"); - const mockOctokit = makeMockOctokit(); - vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - - - const { fetchAllData } = await import("../../src/app/services/poll"); - - // First call — sets _lastSuccessfulFetch - await fetchAllData(); - vi.mocked(fetchIssuesAndPullRequests).mockClear(); - - // Second call — gate checks notifications, gets 403 - mockOctokit.request.mockRejectedValueOnce({ status: 403 }); - await fetchAllData(); - - // Gate received 403 → _notifGateDisabled = true → pushNotification called - expect(pushNotification).toHaveBeenCalledWith( - "notifications", - expect.stringContaining("403"), - "warning" - ); - - // Third call — gate should be DISABLED, no notifications request - mockOctokit.request.mockClear(); - vi.mocked(fetchIssuesAndPullRequests).mockClear(); - vi.mocked(fetchWorkflowRuns).mockClear(); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - - await fetchAllData(); - - expect(mockOctokit.request).not.toHaveBeenCalled(); - // The data fetches still run - expect(fetchIssuesAndPullRequests).toHaveBeenCalled(); - expect(fetchWorkflowRuns).toHaveBeenCalled(); - }); - - it("still fetches data on the same call that triggers the 403", async () => { - vi.resetModules(); - - const { getClient } = await import("../../src/app/services/github"); - const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); - const mockOctokit = makeMockOctokit(); - vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - - - const { fetchAllData } = await import("../../src/app/services/poll"); - - // First call — sets _lastSuccessfulFetch - await fetchAllData(); - vi.mocked(fetchIssuesAndPullRequests).mockClear(); - vi.mocked(fetchWorkflowRuns).mockClear(); - - // Second call — gate returns 403; hasNotificationChanges returns true → full fetch runs - mockOctokit.request.mockRejectedValueOnce({ status: 403 }); - - const result = await fetchAllData(); - - expect(result.skipped).toBeUndefined(); - expect(fetchIssuesAndPullRequests).toHaveBeenCalled(); - expect(fetchWorkflowRuns).toHaveBeenCalled(); - }); - - it("shows PAT-specific 403 notification when authMethod is 'pat'", async () => { - vi.resetModules(); - - // Override config mock to include authMethod: "pat" for this test - vi.doMock("../../src/app/stores/config", () => ({ - config: { - selectedRepos: [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }], - maxWorkflowsPerRepo: 5, - maxRunsPerWorkflow: 3, - authMethod: "pat", - }, - })); - - const { getClient } = await import("../../src/app/services/github"); - const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); - const { pushNotification } = await import("../../src/app/lib/errors"); - const mockOctokit = makeMockOctokit(); - vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); - vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - - const { fetchAllData } = await import("../../src/app/services/poll"); - - // First call — sets _lastSuccessfulFetch - await fetchAllData(); - - // Second call — gate fires a 403 - mockOctokit.request.mockRejectedValueOnce({ status: 403 }); - await fetchAllData(); - - // PAT-specific message should mention fine-grained tokens - expect(pushNotification).toHaveBeenCalledWith( - "notifications", - expect.stringContaining("fine-grained tokens do not support notifications"), - "warning" - ); - }); -}); // ── Upstream repos + tracked users integration ──────────────────────────────── @@ -893,6 +567,7 @@ describe("fetchAllData — 401 propagation from allSettled", () => { }); }); + // ── qa-4: Concurrency verification ──────────────────────────────────────────── describe("fetchAllData — parallel execution", () => { diff --git a/tests/services/poll-notification-effects.test.ts b/tests/services/poll-notification-effects.test.ts deleted file mode 100644 index 809eb4b8..00000000 --- a/tests/services/poll-notification-effects.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Tests for the module-scope reactive effects in poll.ts that reset notification - * state when config.trackedUsers or config.monitoredRepos change. - * - * Uses the REAL reactive config store (not a static mock) so that updateConfig() - * triggers the reactive effects registered by poll.ts at module load. - */ -import "fake-indexeddb/auto"; -import { describe, it, expect, vi, beforeEach } from "vitest"; - -const { mockResetNotifState } = vi.hoisted(() => ({ - mockResetNotifState: vi.fn(), -})); - -// Mock github client -vi.mock("../../src/app/services/github", () => ({ - getClient: vi.fn(), - onApiRequest: vi.fn(), -})); - -// Mock auth store — onAuthCleared is called at poll.ts module scope -vi.mock("../../src/app/stores/auth", () => ({ - user: vi.fn(() => ({ login: "octocat", avatar_url: "https://github.com/images/error/octocat_happy.gif", name: "Octocat" })), - onAuthCleared: vi.fn(), -})); - -// Mock API functions -vi.mock("../../src/app/services/api", () => ({ - fetchIssuesAndPullRequests: vi.fn(), - fetchWorkflowRuns: vi.fn(), - fetchHotPRStatus: vi.fn(), - fetchWorkflowRunById: vi.fn(), - pooledAllSettled: vi.fn(), - resetEmptyActionRepos: vi.fn(), -})); - -// Mock notifications — spy on _resetNotificationState -vi.mock("../../src/app/lib/notifications", () => ({ - detectNewItems: vi.fn(() => []), - dispatchNotifications: vi.fn(), - _resetNotificationState: mockResetNotifState, -})); - -// Mock errors store -vi.mock("../../src/app/lib/errors", () => ({ - pushError: vi.fn(), - pushNotification: vi.fn(), - getErrors: vi.fn().mockReturnValue([]), - dismissError: vi.fn(), - getNotifications: vi.fn().mockReturnValue([]), - getUnreadCount: vi.fn().mockReturnValue(0), - markAllAsRead: vi.fn(), - startCycleTracking: vi.fn(), - endCycleTracking: vi.fn(), - resetNotificationState: vi.fn(), - dismissNotificationBySource: vi.fn(), -})); - -// Use REAL config store — the reactive effects in poll.ts subscribe to this -import { updateConfig, resetConfig } from "../../src/app/stores/config"; - -// Import poll.ts — triggers createRoot + createEffect registration at module scope -import { fetchAllData, resetPollState } from "../../src/app/services/poll"; -import { getClient } from "../../src/app/services/github"; -import { fetchIssuesAndPullRequests, fetchWorkflowRuns } from "../../src/app/services/api"; - -describe("poll.ts — notification reset reactive effects", () => { - beforeEach(() => { - resetConfig(); - mockResetNotifState.mockClear(); - }); - - it("resets notification state when monitoredRepos changes", () => { - updateConfig({ - selectedRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], - monitoredRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], - }); - - expect(mockResetNotifState).toHaveBeenCalled(); - }); - - it("resets notification state when trackedUsers changes", () => { - updateConfig({ - trackedUsers: [{ - login: "octocat", - avatarUrl: "https://avatars.githubusercontent.com/u/583231", - name: "Octocat", - type: "user" as const, - }], - }); - - expect(mockResetNotifState).toHaveBeenCalled(); - }); - - it("does not reset when config update does not change the key", () => { - updateConfig({ theme: "dark" }); - - expect(mockResetNotifState).not.toHaveBeenCalled(); - }); - - it("resets notification state when monitoredRepos cleared to empty", () => { - updateConfig({ - selectedRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], - monitoredRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], - }); - mockResetNotifState.mockClear(); - - updateConfig({ monitoredRepos: [] }); - - expect(mockResetNotifState).toHaveBeenCalled(); - }); - - it("detects swap at same array length (key-based comparison)", () => { - updateConfig({ - selectedRepos: [ - { owner: "org", name: "a", fullName: "org/a" }, - { owner: "org", name: "b", fullName: "org/b" }, - ], - monitoredRepos: [{ owner: "org", name: "a", fullName: "org/a" }], - }); - mockResetNotifState.mockClear(); - - updateConfig({ - monitoredRepos: [{ owner: "org", name: "b", fullName: "org/b" }], - }); - - expect(mockResetNotifState).toHaveBeenCalled(); - }); -}); - -describe("poll.ts — notifications gate bypass on config change", () => { - const mockRequest = vi.fn(); - - beforeEach(() => { - resetPollState(); - resetConfig(); - mockRequest.mockReset(); - mockRequest.mockResolvedValue({ - data: [], - headers: { "last-modified": "Thu, 20 Mar 2026 12:00:00 GMT" }, - }); - vi.mocked(getClient).mockReturnValue({ - request: mockRequest, - graphql: vi.fn(), - hook: { before: vi.fn() }, - } as never); - vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue({ - issues: [], pullRequests: [], errors: [], - }); - vi.mocked(fetchWorkflowRuns).mockResolvedValue({ - workflowRuns: [], errors: [], - } as never); - }); - - it("bypasses notifications gate after monitoredRepos change", async () => { - // First call — no _lastSuccessfulFetch, gate skipped - await fetchAllData(); - expect(mockRequest).not.toHaveBeenCalled(); - - // Second call — _lastSuccessfulFetch set, gate fires - await fetchAllData(); - expect(mockRequest).toHaveBeenCalledWith("GET /notifications", expect.anything()); - mockRequest.mockClear(); - - // Change monitoredRepos — should null _lastSuccessfulFetch - updateConfig({ - selectedRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], - monitoredRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], - }); - - // Third call — gate bypassed because _lastSuccessfulFetch was nulled - await fetchAllData(); - expect(mockRequest).not.toHaveBeenCalled(); - }); - - it("bypasses notifications gate after trackedUsers change", async () => { - // First call — sets _lastSuccessfulFetch - await fetchAllData(); - - // Second call — gate fires - await fetchAllData(); - mockRequest.mockClear(); - - // Change trackedUsers — should null _lastSuccessfulFetch - updateConfig({ - trackedUsers: [{ - login: "octocat", - avatarUrl: "https://avatars.githubusercontent.com/u/583231", - name: "Octocat", - type: "user" as const, - }], - }); - - // Next call — gate bypassed - await fetchAllData(); - expect(mockRequest).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index 8389485a..199e38cb 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -1,7 +1,7 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createRoot, createSignal } from "solid-js"; -import { createPollCoordinator, disableNotifGate, resetPollState, type DashboardData } from "../../src/app/services/poll"; +import { createPollCoordinator, type DashboardData } from "../../src/app/services/poll"; import * as githubMod from "../../src/app/services/github"; // Mock pushError so we can spy on it @@ -135,7 +135,7 @@ describe("createPollCoordinator", () => { }); }); - it("continues polling when document is hidden (notifications gate enabled)", async () => { + it("pauses polling when document is hidden (no 304 shortcut for GraphQL)", async () => { const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); // jitter = 0 const fetchAll = makeFetchAll(); @@ -152,8 +152,8 @@ describe("createPollCoordinator", () => { vi.advanceTimersByTime(61_000); await flushPromises(); - // Should have fetched while hidden (background refresh) - expect(fetchAll.mock.calls.length).toBeGreaterThan(callsAfterInit); + // Should NOT have fetched — background polls skipped (no 304 shortcut) + expect(fetchAll.mock.calls.length).toBe(callsAfterInit); dispose(); }); @@ -186,31 +186,6 @@ describe("createPollCoordinator", () => { }); }); - it("pauses background polling when hidden and notifications gate is disabled", async () => { - disableNotifGate(); - const fetchAll = makeFetchAll(); - - await createRoot(async (dispose) => { - createPollCoordinator(makeGetInterval(60), fetchAll); - await Promise.resolve(); // initial fetch - - const callsAfterInit = fetchAll.mock.calls.length; - - // Hide document - setDocumentVisible(false); - - // Advance past the interval - vi.advanceTimersByTime(90_000); - await Promise.resolve(); - - // Should NOT have fetched — gate disabled means no cheap 304, skip background polls - expect(fetchAll.mock.calls.length).toBe(callsAfterInit); - dispose(); - }); - - resetPollState(); // restore gate for other tests - }); - it("does NOT trigger immediate refresh on re-visible within 2 minutes", async () => { // Pin jitter to 0 so 300s interval is exactly 300s (no background poll in 90s) const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); @@ -238,7 +213,7 @@ describe("createPollCoordinator", () => { randomSpy.mockRestore(); }); - it("resets timer on re-visible after >2 min, preventing double-fire with background polls", async () => { + it("resets timer on re-visible after >2 min, fires catch-up then waits full interval", async () => { // Pin jitter to 0 so 60s interval is exactly 60s const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); const fetchAll = makeFetchAll(); @@ -249,20 +224,20 @@ describe("createPollCoordinator", () => { const callsAfterInit = fetchAll.mock.calls.length; - // Hide for >2 min — background polls fire at 60s and 120s + // Hide for >2 min — background polls are SKIPPED (no 304 shortcut) setDocumentVisible(false); vi.advanceTimersByTime(130_000); await flushPromises(); - const callsWhileHidden = fetchAll.mock.calls.length; - expect(callsWhileHidden).toBeGreaterThan(callsAfterInit); + // No polls while hidden + expect(fetchAll.mock.calls.length).toBe(callsAfterInit); // Restore visibility — catch-up fetch fires + timer resets setDocumentVisible(true); await flushPromises(); const callsAfterRevisible = fetchAll.mock.calls.length; - expect(callsAfterRevisible).toBeGreaterThan(callsWhileHidden); + expect(callsAfterRevisible).toBeGreaterThan(callsAfterInit); // Advance 30s — should NOT fire (timer was reset to full 60s interval) vi.advanceTimersByTime(30_000); @@ -425,12 +400,12 @@ describe("createPollCoordinator", () => { // ── qa-4: Concurrent doFetch guard — second call while first is in-flight ─── - it("concurrent doFetch guard: second manualRefresh while first is in-flight calls fetchAll only once", async () => { - let resolveFirst!: () => void; + it("concurrent doFetch guard: second manualRefresh while first is in-flight queues a force retry", async () => { + const resolvers: Array<() => void> = []; const fetchAll = vi.fn( () => new Promise((resolve) => { - resolveFirst = () => resolve(emptyData); + resolvers.push(() => resolve(emptyData)); }) ); @@ -446,77 +421,22 @@ describe("createPollCoordinator", () => { coordinator.manualRefresh(); await Promise.resolve(); - // Guard should prevent a second concurrent invocation + // Guard should prevent a second concurrent invocation — pendingForce queued instead expect(fetchAll).toHaveBeenCalledTimes(1); - // Resolve the first fetch - resolveFirst(); - await Promise.resolve(); - await Promise.resolve(); - - expect(coordinator.isRefreshing()).toBe(false); - dispose(); - }); - }); - - // ── qa-5: fetchAll returns skipped:true — lastRefreshAt not updated ────────── - - it("does not update lastRefreshAt and does not push errors when fetchAll returns skipped:true", async () => { - mockPushError.mockClear(); - - const skippedData: DashboardData = { - issues: [], - pullRequests: [], - workflowRuns: [], - errors: [], - skipped: true, - }; - const fetchAll = vi.fn().mockResolvedValue(skippedData); - - await createRoot(async (dispose) => { - const coordinator = createPollCoordinator(makeGetInterval(0), fetchAll); + // Resolve the first fetch — the finally block fires the queued force retry + resolvers[0](); + await flushPromises(); - // Wait for the in-flight fetch to settle - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + // The force retry should now be in-flight (fetchAll called twice) + expect(fetchAll).toHaveBeenCalledTimes(2); + expect(coordinator.isRefreshing()).toBe(true); - // lastRefreshAt must remain null — skipped fetch should not record a refresh time - expect(coordinator.lastRefreshAt()).toBeNull(); + // Resolve the second (forced) fetch + resolvers[1](); + await flushPromises(); - // isRefreshing must be cleared — the finally block always runs expect(coordinator.isRefreshing()).toBe(false); - - // pushError must NOT have been called — per-repo errors are only processed on non-skipped fetches - expect(mockPushError).not.toHaveBeenCalled(); - - dispose(); - }); - }); - - // ── qa-3a: doFetch skipped path — no restore (reconciliation replaces snapshot/restore) ── - - it("skipped fetch does NOT call pushError for previous errors (no restore logic)", async () => { - mockPushError.mockClear(); - - const skippedData: DashboardData = { - issues: [], - pullRequests: [], - workflowRuns: [], - errors: [], - skipped: true, - }; - const fetchAll = vi.fn().mockResolvedValue(skippedData); - - await createRoot(async (dispose) => { - createPollCoordinator(makeGetInterval(0), fetchAll); - - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - // No pushError calls on skip — notifications persist naturally - expect(mockPushError).not.toHaveBeenCalled(); dispose(); }); }); @@ -578,33 +498,6 @@ describe("createPollCoordinator", () => { mockEndCycleTracking.mockReturnValue(new Set()); }); - // ── qa-3d: endCycleTracking called on skipped path ──────────────────────────── - - it("endCycleTracking is called on skipped path (no tracking state leak)", async () => { - mockEndCycleTracking.mockClear(); - mockStartCycleTracking.mockClear(); - - const skippedData: DashboardData = { - issues: [], - pullRequests: [], - workflowRuns: [], - errors: [], - skipped: true, - }; - const fetchAll = vi.fn().mockResolvedValue(skippedData); - - await createRoot(async (dispose) => { - createPollCoordinator(makeGetInterval(0), fetchAll); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - // startCycleTracking called, endCycleTracking called in finally - expect(mockStartCycleTracking).toHaveBeenCalled(); - expect(mockEndCycleTracking).toHaveBeenCalled(); - dispose(); - }); - }); // ── qa-11: Jitter test with fixed Math.random to make interval deterministic ── @@ -663,6 +556,37 @@ describe("createPollCoordinator", () => { }); }); + it("destroy() clears pendingForce: queued retry does not fire after destroy", async () => { + const resolvers: Array<() => void> = []; + const fetchAll = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(() => resolve(emptyData)); + }) + ); + + await createRoot(async (dispose) => { + const coordinator = createPollCoordinator(makeGetInterval(0), fetchAll); + + await Promise.resolve(); + expect(fetchAll).toHaveBeenCalledTimes(1); + + coordinator.manualRefresh(); + await Promise.resolve(); + + expect(fetchAll).toHaveBeenCalledTimes(1); + + coordinator.destroy(); + + resolvers[0](); + await flushPromises(); + + expect(fetchAll).toHaveBeenCalledTimes(1); + + dispose(); + }); + }); + it("fetchRateLimitDetails is called exactly once per doFetch cycle", async () => { const fetchRateLimitDetailsSpy = vi.mocked(githubMod.fetchRateLimitDetails); fetchRateLimitDetailsSpy.mockClear(); diff --git a/tests/shared/validation.test.ts b/tests/shared/validation.test.ts new file mode 100644 index 00000000..aa0fab3a --- /dev/null +++ b/tests/shared/validation.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { extractJiraKeys } from "../../src/shared/validation"; + +// ── Jira key pattern (via extractJiraKeys) ─────────────────────────────────── + +describe("Jira key pattern", () => { + it("matches a standard Jira key", () => { + expect(extractJiraKeys("PROJ-123")).toEqual(["PROJ-123"]); + }); + + it("does not match lowercase keys", () => { + expect(extractJiraKeys("proj-123")).toEqual([]); + }); + + it("does not match a key that is a substring within a word boundary", () => { + expect(extractJiraKeys("NOPROJ-1X")).toEqual([]); + }); + + it("matches project prefix of 2 characters minimum", () => { + expect(extractJiraKeys("AB-1 ZZ-99")).toEqual(["AB-1", "ZZ-99"]); + }); + + it("matches project prefix up to 10 characters", () => { + expect(extractJiraKeys("ABCDEFGHIJ-1")).toEqual(["ABCDEFGHIJ-1"]); + }); + + it("does not match project prefix exceeding 10 characters", () => { + expect(extractJiraKeys("ABCDEFGHIJK-1")).not.toContain("ABCDEFGHIJK-1"); + }); + + it("does not match a single uppercase letter prefix (less than 2)", () => { + expect(extractJiraKeys("A-1")).toEqual([]); + }); +}); + +// ── extractJiraKeys ─────────────────────────────────────────────────────────── + +describe("extractJiraKeys", () => { + it("extracts a single key from text", () => { + expect(extractJiraKeys("PROJ-123 fix login")).toEqual(["PROJ-123"]); + }); + + it("extracts multiple distinct keys from text", () => { + const result = extractJiraKeys("PROJ-1 and TEAM-42 need review"); + expect(result).toEqual(["PROJ-1", "TEAM-42"]); + }); + + it("deduplicates repeated keys", () => { + expect(extractJiraKeys("PROJ-1 PROJ-1 PROJ-1")).toEqual(["PROJ-1"]); + }); + + it("deduplicates keys that appear in different positions", () => { + const result = extractJiraKeys("fixes PROJ-1 and also PROJ-1"); + expect(result).toEqual(["PROJ-1"]); + }); + + it("returns empty array when no keys are present", () => { + expect(extractJiraKeys("no jira keys here")).toEqual([]); + }); + + it("returns empty array for empty string", () => { + expect(extractJiraKeys("")).toEqual([]); + }); + + it("does not match lowercase key format", () => { + expect(extractJiraKeys("proj-123 fix")).toEqual([]); + }); + + it("does not match mixed-case key (lowercase suffix)", () => { + expect(extractJiraKeys("Proj-123 fix")).toEqual([]); + }); + + it("does not match key embedded inside a longer word (boundary test)", () => { + // NOPROJ-1X: no word boundary after the digit sequence + expect(extractJiraKeys("NOPROJ-1X")).toEqual([]); + }); + + it("extracts key from branch name format", () => { + expect(extractJiraKeys("feat/PROJ-123-fix-login")).toEqual(["PROJ-123"]); + }); + + it("extracts multiple keys from a branch name with multiple keys", () => { + expect(extractJiraKeys("feat/PROJ-1-and-TEAM-42-work")).toEqual(["PROJ-1", "TEAM-42"]); + }); + + it("extracts key from a PR title with surrounding text", () => { + expect(extractJiraKeys("[PROJ-456] Fix authentication bug")).toEqual(["PROJ-456"]); + }); + + it("handles text with no word boundary after digits correctly (valid boundary)", () => { + // PROJ-1 followed by space — valid word boundary on right + expect(extractJiraKeys("fix PROJ-1 now")).toEqual(["PROJ-1"]); + }); + + it("resets regex lastIndex so repeated calls return correct results", () => { + // Call twice to verify the global regex lastIndex is reset between calls + const first = extractJiraKeys("PROJ-1 TEAM-2"); + const second = extractJiraKeys("PROJ-1 TEAM-2"); + expect(first).toEqual(second); + expect(second).toEqual(["PROJ-1", "TEAM-2"]); + }); + + it("returns keys in order of first appearance", () => { + const result = extractJiraKeys("TEAM-42 PROJ-1 TEAM-42"); + expect(result[0]).toBe("TEAM-42"); + expect(result[1]).toBe("PROJ-1"); + }); +}); diff --git a/tests/stores/auth.test.ts b/tests/stores/auth.test.ts index ca6974df..e1cc3616 100644 --- a/tests/stores/auth.test.ts +++ b/tests/stores/auth.test.ts @@ -540,6 +540,577 @@ describe("cross-tab auth sync", () => { }); }); +// ── Jira auth signals ──────────────────────────────────────────────────────── + +describe("setJiraAuth / jiraAuth signal", () => { + let mod: typeof import("../../src/app/stores/auth"); + + beforeEach(async () => { + localStorageMock.clear(); + vi.resetModules(); + mod = await import("../../src/app/stores/auth"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeJiraAuth(overrides = {}): import("../../src/shared/jira-types").JiraAuthState { + return { + accessToken: "atl-access-tok", + sealedRefreshToken: "sealed-blob", + expiresAt: Date.now() + 3600_000, + cloudId: "cloud-abc", + siteUrl: "https://mysite.atlassian.net", + siteName: "My Site", + ...overrides, + }; + } + + it("setJiraAuth persists to localStorage", () => { + mod.setJiraAuth(makeJiraAuth()); + const stored = localStorageMock.getItem("github-tracker:jira-auth"); + expect(stored).not.toBeNull(); + const parsed = JSON.parse(stored!); + expect(parsed.accessToken).toBe("atl-access-tok"); + expect(parsed.cloudId).toBe("cloud-abc"); + }); + + it("setJiraAuth updates the jiraAuth signal", () => { + const state = makeJiraAuth(); + mod.setJiraAuth(state); + expect(mod.jiraAuth()?.accessToken).toBe("atl-access-tok"); + expect(mod.jiraAuth()?.cloudId).toBe("cloud-abc"); + }); + + it("jiraAuth signal initializes from localStorage on module load", async () => { + const state = makeJiraAuth({ accessToken: "persisted-tok" }); + localStorageMock.setItem("github-tracker:jira-auth", JSON.stringify(state)); + vi.resetModules(); + const fresh = await import("../../src/app/stores/auth"); + expect(fresh.jiraAuth()?.accessToken).toBe("persisted-tok"); + }); + + it("jiraAuth signal starts null when localStorage is empty", () => { + expect(mod.jiraAuth()).toBeNull(); + }); + + it("jiraAuth signal starts null when localStorage contains malformed JSON", async () => { + localStorageMock.setItem("github-tracker:jira-auth", "{{not-json}}"); + vi.resetModules(); + const fresh = await import("../../src/app/stores/auth"); + expect(fresh.jiraAuth()).toBeNull(); + }); + + it("jiraAuth signal starts null and evicts localStorage when JSON is valid but wrong shape", async () => { + localStorageMock.setItem("github-tracker:jira-auth", JSON.stringify({ someField: "value" })); + vi.resetModules(); + const fresh = await import("../../src/app/stores/auth"); + expect(fresh.jiraAuth()).toBeNull(); + expect(localStorageMock.getItem("github-tracker:jira-auth")).toBeNull(); + }); +}); + +describe("isJiraAuthenticated", () => { + let mod: typeof import("../../src/app/stores/auth"); + + beforeEach(async () => { + localStorageMock.clear(); + vi.resetModules(); + mod = await import("../../src/app/stores/auth"); + }); + + it("returns false when no Jira auth state", () => { + expect(mod.isJiraAuthenticated()).toBe(false); + }); + + it("returns true after setJiraAuth", () => { + mod.setJiraAuth({ + accessToken: "tok", + sealedRefreshToken: "sealed", + expiresAt: Date.now() + 3600_000, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + expect(mod.isJiraAuthenticated()).toBe(true); + }); +}); + +describe("clearJiraAuth", () => { + let mod: typeof import("../../src/app/stores/auth"); + let configMod: typeof import("../../src/app/stores/config"); + + beforeEach(async () => { + localStorageMock.clear(); + vi.resetModules(); + mod = await import("../../src/app/stores/auth"); + configMod = await import("../../src/app/stores/config"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("removes jira-auth from localStorage", () => { + mod.setJiraAuth({ + accessToken: "tok", + sealedRefreshToken: "s", + expiresAt: Date.now() + 3600_000, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + expect(localStorageMock.getItem("github-tracker:jira-auth")).not.toBeNull(); + mod.clearJiraAuth(); + expect(localStorageMock.getItem("github-tracker:jira-auth")).toBeNull(); + }); + + it("resets jiraAuth signal to null", () => { + mod.setJiraAuth({ + accessToken: "tok", + sealedRefreshToken: "s", + expiresAt: Date.now() + 3600_000, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + mod.clearJiraAuth(); + expect(mod.jiraAuth()).toBeNull(); + expect(mod.isJiraAuthenticated()).toBe(false); + }); + + it("resets config.jira.enabled to false", () => { + mod.clearJiraAuth(); + expect(configMod.config.jira?.enabled).toBe(false); + }); + + it("resets config.jira.authMethod to oauth default", () => { + mod.clearJiraAuth(); + expect(configMod.config.jira?.authMethod).toBe("oauth"); + }); +}); + +describe("clearAuth clears Jira auth via onAuthCleared", () => { + let mod: typeof import("../../src/app/stores/auth"); + + beforeEach(async () => { + localStorageMock.clear(); + vi.resetModules(); + mod = await import("../../src/app/stores/auth"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("GitHub clearAuth removes jira-auth from localStorage", () => { + localStorageMock.setItem("github-tracker:jira-auth", JSON.stringify({ + accessToken: "tok", + sealedRefreshToken: "s", + expiresAt: 9999999999999, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + })); + mod.setJiraAuth({ + accessToken: "tok", + sealedRefreshToken: "s", + expiresAt: 9999999999999, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + mod.clearAuth(); + expect(localStorageMock.getItem("github-tracker:jira-auth")).toBeNull(); + }); + + it("GitHub clearAuth resets jiraAuth signal to null", () => { + mod.setJiraAuth({ + accessToken: "tok", + sealedRefreshToken: "s", + expiresAt: 9999999999999, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + mod.clearAuth(); + expect(mod.jiraAuth()).toBeNull(); + }); +}); + +describe("ensureJiraTokenValid", () => { + let mod: typeof import("../../src/app/stores/auth"); + let configMod: typeof import("../../src/app/stores/config"); + + beforeEach(async () => { + vi.useFakeTimers(); + localStorageMock.clear(); + vi.resetModules(); + mod = await import("../../src/app/stores/auth"); + configMod = await import("../../src/app/stores/config"); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + function setFreshOAuthJiraAuth() { + mod.setJiraAuth({ + accessToken: "fresh-access-tok", + sealedRefreshToken: "sealed-refresh", + expiresAt: Date.now() + 3600_000, // fresh: 1h from now + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + } + + function setExpiredOAuthJiraAuth() { + mod.setJiraAuth({ + accessToken: "expired-access-tok", + sealedRefreshToken: "sealed-refresh", + expiresAt: Date.now() + 60_000, // expiring: < 5min buffer + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + } + + it("returns false when no jira auth", async () => { + expect(await mod.ensureJiraTokenValid()).toBe(false); + }); + + it("returns true without refresh when token is fresh", async () => { + setFreshOAuthJiraAuth(); + vi.stubGlobal("fetch", vi.fn()); + const result = await mod.ensureJiraTokenValid(); + expect(result).toBe(true); + expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled(); + }); + + it("returns true for API token mode without refresh (authMethod=token guard)", async () => { + mod.setJiraAuth({ + accessToken: "sealed-api-token", + sealedRefreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + configMod.updateJiraConfig({ authMethod: "token" }); + vi.stubGlobal("fetch", vi.fn()); + const result = await mod.ensureJiraTokenValid(); + expect(result).toBe(true); + expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled(); + }); + + it("returns true for empty sealedRefreshToken without refresh (API token mode guard)", async () => { + mod.setJiraAuth({ + accessToken: "sealed-api-token", + sealedRefreshToken: "", + expiresAt: Number.MAX_SAFE_INTEGER, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + vi.stubGlobal("fetch", vi.fn()); + const result = await mod.ensureJiraTokenValid(); + expect(result).toBe(true); + expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled(); + }); + + it("calls refresh endpoint when token is near expiry", async () => { + setExpiredOAuthJiraAuth(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access-tok", + sealed_refresh_token: "new-sealed", + expires_in: 3600, + }), + })); + + const result = await mod.ensureJiraTokenValid(); + expect(result).toBe(true); + expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledWith( + "/api/oauth/jira/refresh", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "X-Requested-With": "fetch" }), + }) + ); + expect(mod.jiraAuth()?.accessToken).toBe("new-access-tok"); + expect(mod.jiraAuth()?.sealedRefreshToken).toBe("new-sealed"); + }); + + it("concurrent ensureJiraTokenValid calls share a single-flight promise", async () => { + setExpiredOAuthJiraAuth(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-tok", + sealed_refresh_token: "new-sealed", + expires_in: 3600, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const [r1, r2, r3] = await Promise.all([ + mod.ensureJiraTokenValid(), + mod.ensureJiraTokenValid(), + mod.ensureJiraTokenValid(), + ]); + expect(r1).toBe(true); + expect(r2).toBe(true); + expect(r3).toBe(true); + // Only one actual fetch — concurrent calls share the same promise + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("failed refresh (401) clears Jira auth", async () => { + setExpiredOAuthJiraAuth(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + })); + + const result = await mod.ensureJiraTokenValid(); + expect(result).toBe(false); + expect(mod.jiraAuth()).toBeNull(); + expect(mod.isJiraAuthenticated()).toBe(false); + }); + + it("network error preserves tokens and returns false", async () => { + setExpiredOAuthJiraAuth(); + vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new TypeError("Failed to fetch"))); + + const result = await mod.ensureJiraTokenValid(); + expect(result).toBe(false); + // Token preserved — network error is not auth failure + expect(mod.jiraAuth()?.accessToken).toBe("expired-access-tok"); + expect(mod.isJiraAuthenticated()).toBe(true); + }); + + it("non-401 server error preserves tokens and returns false", async () => { + setExpiredOAuthJiraAuth(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: false, + status: 503, + })); + + const result = await mod.ensureJiraTokenValid(); + expect(result).toBe(false); + expect(mod.jiraAuth()?.accessToken).toBe("expired-access-tok"); + }); + + it("uses fallback expiresAt of 3600s when refresh response expires_in is 0", async () => { + setExpiredOAuthJiraAuth(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access-tok", + sealed_refresh_token: "new-sealed", + expires_in: 0, + }), + })); + + const before = Date.now(); + const result = await mod.ensureJiraTokenValid(); + const after = Date.now(); + + expect(result).toBe(true); + expect(mod.jiraAuth()?.accessToken).toBe("new-access-tok"); + expect(mod.jiraAuth()?.sealedRefreshToken).toBe("new-sealed"); + const expiresAt = mod.jiraAuth()!.expiresAt; + expect(expiresAt).toBeGreaterThanOrEqual(before + 3600_000); + expect(expiresAt).toBeLessThanOrEqual(after + 3600_000); + }); + + it("uses fallback expiresAt of 3600s when refresh response expires_in is negative", async () => { + setExpiredOAuthJiraAuth(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access-tok", + sealed_refresh_token: "new-sealed", + expires_in: -1, + }), + })); + + const before = Date.now(); + const result = await mod.ensureJiraTokenValid(); + const after = Date.now(); + + expect(result).toBe(true); + expect(mod.jiraAuth()?.accessToken).toBe("new-access-tok"); + expect(mod.jiraAuth()?.sealedRefreshToken).toBe("new-sealed"); + const expiresAt = mod.jiraAuth()!.expiresAt; + expect(expiresAt).toBeGreaterThanOrEqual(before + 3600_000); + expect(expiresAt).toBeLessThanOrEqual(after + 3600_000); + }); + + it("uses fallback expiresAt of 3600s when refresh response expires_in is missing", async () => { + setExpiredOAuthJiraAuth(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access-tok", + sealed_refresh_token: "new-sealed", + }), + })); + + const before = Date.now(); + const result = await mod.ensureJiraTokenValid(); + const after = Date.now(); + + expect(result).toBe(true); + expect(mod.jiraAuth()?.accessToken).toBe("new-access-tok"); + expect(mod.jiraAuth()?.sealedRefreshToken).toBe("new-sealed"); + const expiresAt = mod.jiraAuth()!.expiresAt; + expect(expiresAt).toBeGreaterThanOrEqual(before + 3600_000); + expect(expiresAt).toBeLessThanOrEqual(after + 3600_000); + }); + + it("returns false and preserves auth state when refresh response is missing sealed_refresh_token", async () => { + setExpiredOAuthJiraAuth(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-tok", + expires_in: 3600, + }), + })); + + const result = await mod.ensureJiraTokenValid(); + expect(result).toBe(false); + expect(mod.jiraAuth()?.accessToken).toBe("expired-access-tok"); + expect(mod.jiraAuth()?.sealedRefreshToken).toBe("sealed-refresh"); + }); +}); + +describe("cross-tab Jira auth sync", () => { + let mod: typeof import("../../src/app/stores/auth"); + + beforeEach(async () => { + localStorageMock.clear(); + vi.resetModules(); + mod = await import("../../src/app/stores/auth"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("updates jiraAuth signal when another tab writes a new value", () => { + const newState = { + accessToken: "new-tok-from-other-tab", + sealedRefreshToken: "new-sealed", + expiresAt: 9999999999999, + cloudId: "c2", + siteUrl: "https://y.atlassian.net", + siteName: "Y", + }; + localStorageMock.setItem("github-tracker:jira-auth", JSON.stringify(newState)); + + window.dispatchEvent(new StorageEvent("storage", { + key: "github-tracker:jira-auth", + newValue: JSON.stringify(newState), + })); + + expect(mod.jiraAuth()?.accessToken).toBe("new-tok-from-other-tab"); + }); + + it("resets jiraAuth signal to null when another tab removes the key", () => { + mod.setJiraAuth({ + accessToken: "tok", + sealedRefreshToken: "s", + expiresAt: 9999999999999, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + + window.dispatchEvent(new StorageEvent("storage", { + key: "github-tracker:jira-auth", + newValue: null, + })); + + expect(mod.jiraAuth()).toBeNull(); + }); + + it("does not react to unrelated storage keys", () => { + mod.setJiraAuth({ + accessToken: "tok", + sealedRefreshToken: "s", + expiresAt: 9999999999999, + cloudId: "c1", + siteUrl: "https://x.atlassian.net", + siteName: "X", + }); + + window.dispatchEvent(new StorageEvent("storage", { + key: "github-tracker:config", + newValue: null, + })); + + expect(mod.jiraAuth()?.accessToken).toBe("tok"); + }); +}); + +describe("JiraConfigSchema defaults", () => { + it("parse({}) produces correct defaults", async () => { + vi.resetModules(); + const { JiraConfigSchema } = await import("../../src/shared/schemas"); + const result = JiraConfigSchema.parse({}); + expect(result.enabled).toBe(false); + expect(result.authMethod).toBe("oauth"); + expect(result.issueKeyDetection).toBe(true); + expect(result.cloudId).toBeUndefined(); + expect(result.siteUrl).toBeUndefined(); + expect(result.siteName).toBeUndefined(); + expect(result.email).toBeUndefined(); + }); + + it("parse with partial fields fills defaults for missing ones", async () => { + vi.resetModules(); + const { JiraConfigSchema } = await import("../../src/shared/schemas"); + const result = JiraConfigSchema.parse({ enabled: true, authMethod: "token", cloudId: "c1" }); + expect(result.enabled).toBe(true); + expect(result.authMethod).toBe("token"); + expect(result.cloudId).toBe("c1"); + expect(result.issueKeyDetection).toBe(true); // default preserved + expect(result.siteUrl).toBeUndefined(); + }); + + it("ConfigSchema.parse({}) nests jira with correct defaults", async () => { + vi.resetModules(); + const { ConfigSchema } = await import("../../src/shared/schemas"); + const result = ConfigSchema.parse({}); + expect(result.jira.enabled).toBe(false); + expect(result.jira.authMethod).toBe("oauth"); + expect(result.jira.issueKeyDetection).toBe(true); + }); + + it("ConfigSchema preserves existing non-jira fields when jira defaults apply", async () => { + vi.resetModules(); + const { ConfigSchema } = await import("../../src/shared/schemas"); + const result = ConfigSchema.parse({ theme: "dark", refreshInterval: 120 }); + expect(result.theme).toBe("dark"); + expect(result.refreshInterval).toBe(120); + expect(result.jira.enabled).toBe(false); + }); +}); + describe("setAuthFromPat", () => { let mod: typeof import("../../src/app/stores/auth"); let configMod: typeof import("../../src/app/stores/config"); diff --git a/tests/stores/view-lock.test.ts b/tests/stores/view-lock.test.ts index 9ef1668b..48057686 100644 --- a/tests/stores/view-lock.test.ts +++ b/tests/stores/view-lock.test.ts @@ -212,7 +212,7 @@ describe("view lock store (per-tab)", () => { const result = ViewStateSchema.safeParse({}); expect(result.success).toBe(true); if (result.success) { - expect(result.data.lockedRepos).toEqual({ issues: [], pullRequests: [], actions: [] }); + expect(result.data.lockedRepos).toEqual({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }); } }); @@ -247,6 +247,7 @@ describe("view lock store (per-tab)", () => { issues: ["org/a", "org/b"], pullRequests: ["org/a", "org/b"], actions: ["org/a", "org/b"], + jiraAssigned: [], }); }); @@ -264,8 +265,8 @@ describe("view lock store (per-tab)", () => { }); it("returns default record for undefined/null", () => { - expect(migrateLockedRepos(undefined)).toEqual({ issues: [], pullRequests: [], actions: [] }); - expect(migrateLockedRepos(null)).toEqual({ issues: [], pullRequests: [], actions: [] }); + expect(migrateLockedRepos(undefined)).toEqual({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }); + expect(migrateLockedRepos(null)).toEqual({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }); }); it("caps flat array at LOCKED_REPOS_CAP (50) before copying", () => { @@ -285,8 +286,8 @@ describe("view lock store (per-tab)", () => { }); it("returns default record for non-array, non-object inputs", () => { - expect(migrateLockedRepos(42)).toEqual({ issues: [], pullRequests: [], actions: [] }); - expect(migrateLockedRepos("bad")).toEqual({ issues: [], pullRequests: [], actions: [] }); + expect(migrateLockedRepos(42)).toEqual({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }); + expect(migrateLockedRepos("bad")).toEqual({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }); }); it("filters out non-string elements from a flat mixed-type array", () => { diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index 4a856dce..74fafc2c 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -24,6 +24,8 @@ import { resetCustomTabFilters, removeCustomTabState, lockRepo, + untrackJiraItem, + moveJiraItem, } from "../../src/app/stores/view"; import type { IgnoredItem, TrackedItem } from "../../src/app/stores/view"; @@ -260,7 +262,7 @@ describe("ViewStateSchema", () => { it("missing expandedRepos field parses to defaults", () => { const result = ViewStateSchema.parse({ lastActiveTab: "actions" }); - expect(result.expandedRepos).toEqual({ issues: {}, pullRequests: {}, actions: {} }); + expect(result.expandedRepos).toEqual({ issues: {}, pullRequests: {}, actions: {}, jiraAssigned: {} }); }); it("old localStorage data with sortPreferences parses cleanly with globalSort default", () => { @@ -420,6 +422,33 @@ describe("resetAllTabFilters — scope reset", () => { resetViewState(); expect(viewState.hideDepDashboard).toBe(true); }); + + it("resets jiraAssigned filters back to defaults", () => { + setTabFilter("jiraAssigned", "statusCategory", "indeterminate"); + expect(viewState.tabFilters.jiraAssigned.statusCategory).toBe("indeterminate"); + resetAllTabFilters("jiraAssigned"); + expect(viewState.tabFilters.jiraAssigned.statusCategory).toBe("all"); + expect(viewState.tabFilters.jiraAssigned.priority).toBe("all"); + }); +}); + +describe("setTabFilter — jiraAssigned", () => { + it("sets jiraAssigned statusCategory filter", () => { + setTabFilter("jiraAssigned", "statusCategory", "new"); + expect(viewState.tabFilters.jiraAssigned.statusCategory).toBe("new"); + }); + + it("sets jiraAssigned priority filter", () => { + setTabFilter("jiraAssigned", "priority", "High"); + expect(viewState.tabFilters.jiraAssigned.priority).toBe("High"); + }); + + it("preserves other jiraAssigned filters when setting one", () => { + setTabFilter("jiraAssigned", "statusCategory", "indeterminate"); + setTabFilter("jiraAssigned", "priority", "Medium"); + expect(viewState.tabFilters.jiraAssigned.statusCategory).toBe("indeterminate"); + expect(viewState.tabFilters.jiraAssigned.priority).toBe("Medium"); + }); }); describe("tracked items", () => { @@ -427,6 +456,7 @@ describe("tracked items", () => { id: 1001, number: 101, type: "issue", + source: "github", repoFullName: "owner/repo", title: "Bug fix", addedAt: 1711000000000, @@ -435,6 +465,7 @@ describe("tracked items", () => { id: 2002, number: 202, type: "pullRequest", + source: "github", repoFullName: "owner/repo", title: "Add feature", addedAt: 1711000001000, @@ -443,6 +474,7 @@ describe("tracked items", () => { id: 3003, number: 303, type: "issue", + source: "github", repoFullName: "owner/other", title: "Another issue", addedAt: 1711000002000, @@ -476,12 +508,12 @@ describe("tracked items", () => { it("evicts oldest item when at 200 cap (FIFO)", () => { // Fill to 200 for (let i = 0; i < 200; i++) { - trackItem({ id: i, number: i, type: "issue", repoFullName: "o/r", title: `T${i}`, addedAt: 1000 + i }); + trackItem({ id: i, number: i, type: "issue", source: "github", repoFullName: "o/r", title: `T${i}`, addedAt: 1000 + i }); } expect(viewState.trackedItems).toHaveLength(200); // Adding 201st should evict item with id:0 (oldest) - trackItem({ id: 9999, number: 9999, type: "issue", repoFullName: "o/r", title: "New", addedAt: 2000 }); + trackItem({ id: 9999, number: 9999, type: "issue", source: "github", repoFullName: "o/r", title: "New", addedAt: 2000 }); expect(viewState.trackedItems).toHaveLength(200); expect(viewState.trackedItems[0].id).toBe(1); // id:0 evicted expect(viewState.trackedItems[199].id).toBe(9999); @@ -609,6 +641,92 @@ describe("tracked items", () => { expect(result.lastActiveTab).toBe("tracked"); }); }); + + describe("Jira tracked items", () => { + const jiraItem: TrackedItem = { + id: -1234, + type: "jiraIssue", + source: "jira", + jiraKey: "PROJ-42", + jiraProjectKey: "PROJ", + jiraStatus: "In Progress", + repoFullName: "mysite.atlassian.net/PROJ", + title: "Fix login bug", + htmlUrl: "https://mysite.atlassian.net/browse/PROJ-42", + addedAt: 1711000003000, + }; + + const jiraItem2: TrackedItem = { + id: -5678, + type: "jiraIssue", + source: "jira", + jiraKey: "TEAM-99", + jiraProjectKey: "TEAM", + jiraStatus: "To Do", + repoFullName: "mysite.atlassian.net/TEAM", + title: "Add dashboard", + htmlUrl: "https://mysite.atlassian.net/browse/TEAM-99", + addedAt: 1711000004000, + }; + + it("trackItem adds a Jira item with source=jira", () => { + trackItem(jiraItem); + expect(viewState.trackedItems).toHaveLength(1); + expect(viewState.trackedItems[0].source).toBe("jira"); + expect(viewState.trackedItems[0].jiraKey).toBe("PROJ-42"); + }); + + it("trackItem deduplicates by jiraKey for source=jira (not by id)", () => { + trackItem(jiraItem); + trackItem({ ...jiraItem, id: 9999 }); + expect(viewState.trackedItems).toHaveLength(1); + }); + + it("trackItem allows same id with different source", () => { + const githubItem: TrackedItem = { ...item1, id: jiraItem.id }; + trackItem(githubItem); + trackItem(jiraItem); + expect(viewState.trackedItems).toHaveLength(2); + }); + + it("untrackJiraItem removes by jiraKey", () => { + trackItem(jiraItem); + trackItem(jiraItem2); + untrackJiraItem("PROJ-42"); + expect(viewState.trackedItems).toHaveLength(1); + expect(viewState.trackedItems[0].jiraKey).toBe("TEAM-99"); + }); + + it("untrackJiraItem does not affect GitHub items", () => { + trackItem(item1); + trackItem(jiraItem); + untrackJiraItem("PROJ-42"); + expect(viewState.trackedItems).toHaveLength(1); + expect(viewState.trackedItems[0].source).toBe("github"); + }); + + it("moveJiraItem reorders by jiraKey", () => { + trackItem(jiraItem); + trackItem(jiraItem2); + moveJiraItem("TEAM-99", "up"); + expect(viewState.trackedItems[0].jiraKey).toBe("TEAM-99"); + expect(viewState.trackedItems[1].jiraKey).toBe("PROJ-42"); + }); + + it("mixed GitHub + Jira items coexist", () => { + trackItem(item1); + trackItem(jiraItem); + trackItem(item2); + trackItem(jiraItem2); + expect(viewState.trackedItems).toHaveLength(4); + }); + + it("items without source field default to 'github' after schema parse", () => { + const legacy = { id: 100, number: 10, type: "issue" as const, repoFullName: "o/r", title: "old", addedAt: 0 }; + const parsed = ViewStateSchema.parse({ trackedItems: [legacy] }); + expect(parsed.trackedItems[0].source).toBe("github"); + }); + }); }); // ── Custom tab view state (setCustomTabFilter, resetCustomTabFilters, removeCustomTabState) ── diff --git a/tests/worker/jira-oauth.test.ts b/tests/worker/jira-oauth.test.ts new file mode 100644 index 00000000..81a51dca --- /dev/null +++ b/tests/worker/jira-oauth.test.ts @@ -0,0 +1,1157 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import worker, { type Env } from "../../src/worker/index"; +import { collectLogs, ALLOWED_ORIGIN } from "./helpers"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const TEST_SESSION_KEY = "dGVzdC1zZXNzaW9uLWtleQ=="; // "test-session-key" base64 +const TEST_SEAL_KEY = "dGVzdC1zZWFsLWtleQ=="; // "test-seal-key" base64 + +const TEST_EMAIL = "jira-user@example.com"; +const TEST_API_TOKEN = "plaintext-api-token-secret"; +// Valid UUID v4 cloudId +const VALID_CLOUD_ID = "a1b2c3d4-1234-4abc-89ef-a1b2c3d4e5f6"; + +let _requestCounter = 0; + +// ── Env factory ─────────────────────────────────────────────────────────────── + +function makeEnv(overrides: Partial = {}): Env { + return { + ASSETS: { fetch: async () => new Response("asset") }, + GITHUB_CLIENT_ID: "test_client_id", + GITHUB_CLIENT_SECRET: "test_client_secret", + JIRA_CLIENT_ID: "jira-test-client-id", + JIRA_CLIENT_SECRET: "jira-test-client-secret", + ALLOWED_ORIGIN, + SESSION_KEY: TEST_SESSION_KEY, + SEAL_KEY: TEST_SEAL_KEY, + SENTRY_DSN: undefined, + TURNSTILE_SECRET_KEY: "test-turnstile-secret", + PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: true }) }, + ...overrides, + }; +} + +// ── Request helpers ─────────────────────────────────────────────────────────── + +/** Make a Jira token-exchange or refresh request (OAuth path). */ +function makeJiraOAuthRequest( + path: string, + body: unknown, + options: { origin?: string; contentType?: string; turnstileToken?: string; skipXRequestedWith?: boolean } = {} +): Request { + const headers: Record = { + "CF-Connecting-IP": `10.2.0.${++_requestCounter}`, + "Origin": options.origin ?? ALLOWED_ORIGIN, + "Content-Type": options.contentType ?? "application/json", + }; + if (!options.skipXRequestedWith) { + headers["X-Requested-With"] = "fetch"; + } + if (options.turnstileToken !== undefined) { + headers["cf-turnstile-response"] = options.turnstileToken; + } else { + // Default: present but not required for refresh (only exchange needs it) + headers["cf-turnstile-response"] = "valid-turnstile-token"; + } + return new Request(`https://gh.gordoncode.dev${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + +/** Make a Jira proxy request (proxy path — requires Origin, X-Requested-With, Content-Type). */ +function makeJiraProxyRequest( + body: unknown, + options: { origin?: string; addXRequestedWith?: boolean } = {} +): Request { + const headers: Record = { + "CF-Connecting-IP": `10.3.0.${++_requestCounter}`, + "Origin": options.origin ?? ALLOWED_ORIGIN, + "Content-Type": "application/json", + }; + if (options.addXRequestedWith !== false) { + headers["X-Requested-With"] = "fetch"; + } + return new Request("https://gh.gordoncode.dev/api/jira/proxy", { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + +/** Seal a plain token using the Worker's seal endpoint so proxy tests have a valid sealed blob. */ +async function sealTestToken(token: string, purpose: "jira-api-token" | "jira-refresh-token"): Promise { + // Mock Turnstile success for the seal step + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "seal" }), { status: 200 }) + ); + globalThis.fetch = fetchMock; + + const req = new Request("https://gh.gordoncode.dev/api/proxy/seal", { + method: "POST", + headers: { + "CF-Connecting-IP": `10.5.0.${++_requestCounter}`, + "Origin": ALLOWED_ORIGIN, + "X-Requested-With": "fetch", + "Content-Type": "application/json", + "cf-turnstile-response": "valid-turnstile-token", + }, + body: JSON.stringify({ token, purpose }), + }); + + const res = await worker.fetch(req, makeEnv()); + const json = await res.json() as Record; + return json["sealed"] as string; +} + +// ── Jira Token Exchange (/api/oauth/jira/token) ─────────────────────────────── + +describe("POST /api/oauth/jira/token — Jira token exchange", () => { + let originalFetch: typeof globalThis.fetch; + let consoleSpy: { + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + originalFetch = globalThis.fetch; + consoleSpy = { + info: vi.spyOn(console, "info").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("returns 404 when JIRA_CLIENT_ID is not configured", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "valid-code-123" }); + const res = await worker.fetch(req, makeEnv({ JIRA_CLIENT_ID: undefined })); + expect(res.status).toBe(404); + const json = await res.json() as Record; + expect(json["error"]).toBe("not_found"); + }); + + it("returns 404 when JIRA_CLIENT_SECRET is not configured", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "valid-code-123" }); + const res = await worker.fetch(req, makeEnv({ JIRA_CLIENT_SECRET: undefined })); + expect(res.status).toBe(404); + }); + + it("returns 400 when code is missing from body", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "jira-token" }), { status: 200 }) + ); + const req = makeJiraOAuthRequest("/api/oauth/jira/token", {}); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("returns 400 when code is empty string", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "jira-token" }), { status: 200 }) + ); + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("returns 400 when code is not a string", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "jira-token" }), { status: 200 }) + ); + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: 12345 }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + + it("returns 415 when Content-Type is not application/json", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "abc" }, { contentType: "text/plain" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(415); + }); + + it("returns 403 when X-Requested-With header is missing", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "abc" }, { skipXRequestedWith: true }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("missing_csrf_header"); + }); + + it("returns access_token, sealed_refresh_token, expires_in on success", async () => { + // fetch called twice: once for Turnstile verification, once for Atlassian token exchange + globalThis.fetch = vi.fn() + .mockResolvedValueOnce( + // Turnstile verification + new Response(JSON.stringify({ success: true, action: "jira-token" }), { status: 200 }) + ) + .mockResolvedValueOnce( + // Atlassian token exchange + new Response(JSON.stringify({ + access_token: "atlassian-access-token", + refresh_token: "atlassian-refresh-token", + expires_in: 3600, + }), { status: 200 }) + ); + + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "valid-jira-code" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + + const json = await res.json() as Record; + expect(json["access_token"]).toBe("atlassian-access-token"); + expect(typeof json["sealed_refresh_token"]).toBe("string"); + expect((json["sealed_refresh_token"] as string).length).toBeGreaterThan(0); + expect(json["expires_in"]).toBe(3600); + // Must not include plaintext refresh token + expect(json["refresh_token"]).toBeUndefined(); + }); + + it("sealed_refresh_token is not the plaintext refresh token", async () => { + const plainRefreshToken = "plaintext-refresh-token-secret"; + globalThis.fetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "jira-token" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: "atl-access-tok", + refresh_token: plainRefreshToken, + expires_in: 3600, + }), { status: 200 }) + ); + + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "some-code" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(json["sealed_refresh_token"]).not.toBe(plainRefreshToken); + }); + + it("returns jira_token_exchange_failed when Atlassian response lacks access_token", async () => { + globalThis.fetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "jira-token" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }) + ); + + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "bad-code" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("jira_token_exchange_failed"); + }); + + it("returns jira_token_exchange_failed when Atlassian fetch throws", async () => { + globalThis.fetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "jira-token" }), { status: 200 }) + ) + .mockRejectedValueOnce(new Error("network timeout")); + + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "any-code" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("jira_token_exchange_failed"); + }); + + it("returns 429 after exceeding rate limit from same IP", async () => { + // First mock: Turnstile success for all requests; second mock: Atlassian success for non-rate-limited + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, access_token: "tok", refresh_token: "ref", expires_in: 3600 }), { status: 200 }) + ); + + const fixedIp = "10.2.99.5"; + function makeFixedIpRequest() { + return new Request("https://gh.gordoncode.dev/api/oauth/jira/token", { + method: "POST", + headers: { + "CF-Connecting-IP": fixedIp, + "Origin": ALLOWED_ORIGIN, + "Content-Type": "application/json", + "X-Requested-With": "fetch", + "cf-turnstile-response": "valid-turnstile-token", + }, + body: JSON.stringify({ code: "test-code" }), + }); + } + + const env = makeEnv(); + // Exhaust 10-request limit + for (let i = 0; i < 10; i++) { + await worker.fetch(makeFixedIpRequest(), env); + } + const limited = await worker.fetch(makeFixedIpRequest(), env); + expect(limited.status).toBe(429); + const json = await limited.json() as Record; + expect(json["error"]).toBe("rate_limited"); + }); + + it("CORS headers are set correctly on success", async () => { + globalThis.fetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "jira-token" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: "tok", + refresh_token: "ref", + expires_in: 3600, + }), { status: 200 }) + ); + + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "valid-code" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(ALLOWED_ORIGIN); + expect(res.headers.get("Access-Control-Allow-Methods")).toBe("POST"); + }); + + it("CORS headers absent for wrong origin", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: "x" }, { origin: "https://evil.com" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); + + it("OPTIONS /api/oauth/jira/token returns 204 with CORS headers", async () => { + const req = new Request("https://gh.gordoncode.dev/api/oauth/jira/token", { + method: "OPTIONS", + headers: { "Origin": ALLOWED_ORIGIN, "CF-Connecting-IP": `10.2.0.${++_requestCounter}` }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(ALLOWED_ORIGIN); + }); + + it("logs do not contain plaintext codes or secrets", async () => { + const sensitiveCode = "super-secret-jira-code-12345"; + const sensitiveSecret = "jira-test-client-secret"; + + globalThis.fetch = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ success: true, action: "jira-token" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + access_token: "atl-tok", + refresh_token: "atl-ref-tok-secret", + expires_in: 3600, + }), { status: 200 })); + + const req = makeJiraOAuthRequest("/api/oauth/jira/token", { code: sensitiveCode }); + await worker.fetch(req, makeEnv()); + + const logs = collectLogs(consoleSpy); + const allLogText = logs.map((l) => JSON.stringify(l.entry)).join("\n"); + expect(allLogText).not.toContain(sensitiveCode); + expect(allLogText).not.toContain(sensitiveSecret); + expect(allLogText).not.toContain("atl-ref-tok-secret"); + }); +}); + +// ── Jira Token Refresh (/api/oauth/jira/refresh) ────────────────────────────── + +describe("POST /api/oauth/jira/refresh — Jira token refresh", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("returns 404 when JIRA_CLIENT_ID is not configured", async () => { + const req = makeJiraOAuthRequest("/api/oauth/jira/refresh", { sealed_refresh_token: "any" }); + const res = await worker.fetch(req, makeEnv({ JIRA_CLIENT_ID: undefined })); + expect(res.status).toBe(404); + const json = await res.json() as Record; + expect(json["error"]).toBe("not_found"); + }); + + it("returns 400 when sealed_refresh_token is missing", async () => { + const req = makeJiraOAuthRequest("/api/oauth/jira/refresh", {}); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("returns 400 when sealed_refresh_token is empty string", async () => { + const req = makeJiraOAuthRequest("/api/oauth/jira/refresh", { sealed_refresh_token: "" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + + it("returns 401 when sealed_refresh_token cannot be unsealed (corrupted blob)", async () => { + const req = makeJiraOAuthRequest("/api/oauth/jira/refresh", { sealed_refresh_token: "not-a-real-sealed-blob" }); + const res = await worker.fetch(req, makeEnv()); + // Unseal returns null → 401 + expect(res.status).toBe(401); + const json = await res.json() as Record; + expect(json["error"]).toBe("jira_refresh_failed"); + }); + + it("returns new access_token and sealed_refresh_token on valid refresh", async () => { + // First seal a real refresh token + const sealed = await sealTestToken("real-refresh-token-value", "jira-refresh-token"); + + // Now call the refresh endpoint with the sealed token + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + }), { status: 200 }) + ); + + const req = makeJiraOAuthRequest("/api/oauth/jira/refresh", { sealed_refresh_token: sealed }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + + const json = await res.json() as Record; + expect(json["access_token"]).toBe("new-access-token"); + expect(typeof json["sealed_refresh_token"]).toBe("string"); + expect((json["sealed_refresh_token"] as string).length).toBeGreaterThan(0); + expect(json["expires_in"]).toBe(3600); + // Plaintext refresh token must not be returned + expect(json["refresh_token"]).toBeUndefined(); + }); + + it("returns jira_refresh_failed when Atlassian refresh call fails", async () => { + const sealed = await sealTestToken("refresh-token", "jira-refresh-token"); + + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }) + ); + + const req = makeJiraOAuthRequest("/api/oauth/jira/refresh", { sealed_refresh_token: sealed }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("jira_refresh_failed"); + }); + + it("returns 403 when X-Requested-With header is missing", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraOAuthRequest("/api/oauth/jira/refresh", { sealed_refresh_token: "dummy" }, { skipXRequestedWith: true }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("missing_csrf_header"); + }); + + it("returns 429 after exceeding rate limit from same IP", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const fixedIp = "10.2.99.6"; + function makeFixedIpRefreshRequest() { + return new Request("https://gh.gordoncode.dev/api/oauth/jira/refresh", { + method: "POST", + headers: { + "CF-Connecting-IP": fixedIp, + "Origin": ALLOWED_ORIGIN, + "Content-Type": "application/json", + "X-Requested-With": "fetch", + }, + body: JSON.stringify({ sealed_refresh_token: "dummy" }), + }); + } + + const env = makeEnv(); + for (let i = 0; i < 30; i++) { + await worker.fetch(makeFixedIpRefreshRequest(), env); + } + const limited = await worker.fetch(makeFixedIpRefreshRequest(), env); + expect(limited.status).toBe(429); + expect((await limited.json() as Record)["error"]).toBe("rate_limited"); + }); + + it("OPTIONS /api/oauth/jira/refresh returns 204 with CORS headers", async () => { + const req = new Request("https://gh.gordoncode.dev/api/oauth/jira/refresh", { + method: "OPTIONS", + headers: { "Origin": ALLOWED_ORIGIN, "CF-Connecting-IP": `10.2.0.${++_requestCounter}` }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(ALLOWED_ORIGIN); + }); +}); + +// ── Jira Proxy (/api/jira/proxy) ────────────────────────────────────────────── + +describe("POST /api/jira/proxy — Jira API proxy", () => { + let originalFetch: typeof globalThis.fetch; + let consoleSpy: { + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + let sealedToken: string; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + consoleSpy = { + info: vi.spyOn(console, "info").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + }; + sealedToken = await sealTestToken(TEST_API_TOKEN, "jira-api-token"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + // ── 404 when unconfigured ───────────────────────────────────────────────── + + it("returns 404 when JIRA_CLIENT_ID is not configured", async () => { + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = currentUser()", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv({ JIRA_CLIENT_ID: undefined })); + expect(res.status).toBe(404); + const json = await res.json() as Record; + expect(json["error"]).toBe("not_found"); + }); + + // ── Endpoint allowlist ──────────────────────────────────────────────────── + + it("allows endpoint=search", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 10, startAt: 0 }), { status: 200 }) + ); + + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = currentUser()", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + }); + + it("allows endpoint=issue", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [{ id: "1", key: "PROJ-1", self: "", fields: {} }] }), { status: 200 }) + ); + + const req = makeJiraProxyRequest({ + endpoint: "issue", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { issueIdsOrKeys: ["PROJ-1"], fields: ["summary"] }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + }); + + it("rejects endpoint=projects (not in allowlist)", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "projects", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: {}, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("rejects endpoint=../../admin (path traversal attempt)", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "../../admin", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: {}, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + + // ── cloudId validation ──────────────────────────────────────────────────── + + it("rejects non-UUID cloudId (plain string)", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: "my-cloud-id", + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = me", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + + it("rejects cloudId with path traversal characters", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: "../../admin", + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = me", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + + it("rejects all-dashes cloudId (permissive but incorrect format)", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: "------------------------------------", + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = me", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + + it("accepts a valid UUID v4 cloudId", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 10, startAt: 0 }), { status: 200 }) + ); + + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = currentUser()", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + }); + + // ── Target URL construction ─────────────────────────────────────────────── + + it("constructs correct target URL for search endpoint", async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 10, startAt: 0 }), { status: 200 }) + ); + globalThis.fetch = mockFetch; + + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "project = TEST", maxResults: 10 }, + }); + await worker.fetch(req, makeEnv()); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain(`https://api.atlassian.com/ex/jira/${VALID_CLOUD_ID}/rest/api/3/search/jql`); + expect(url).toContain("jql=project+%3D+TEST"); + expect((init.headers as Record)["Authorization"]).toMatch(/^Basic /); + expect(init.method).toBe("GET"); + }); + + it("constructs correct target URL for issue endpoint", async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [] }), { status: 200 }) + ); + globalThis.fetch = mockFetch; + + const req = makeJiraProxyRequest({ + endpoint: "issue", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { issueIdsOrKeys: ["PROJ-1"], fields: ["summary"] }, + }); + await worker.fetch(req, makeEnv()); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`https://api.atlassian.com/ex/jira/${VALID_CLOUD_ID}/rest/api/3/issue/bulkfetch`); + expect(init.method).toBe("POST"); + }); + + // ── maxResults cap ──────────────────────────────────────────────────────── + + it("rejects search request when maxResults exceeds 100", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = me", maxResults: 200 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("rejects search request when maxResults is absent", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = me" }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + }); + + it("allows search request with maxResults=100", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 100, startAt: 0 }), { status: 200 }) + ); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = currentUser()", maxResults: 100 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + }); + + // ── issueIdsOrKeys cap ──────────────────────────────────────────────────── + + it("rejects issueIdsOrKeys with more than 100 items", async () => { + globalThis.fetch = vi.fn(); + const keys101 = Array.from({ length: 101 }, (_, i) => `PROJ-${i}`); + const req = makeJiraProxyRequest({ + endpoint: "issue", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { issueIdsOrKeys: keys101, fields: ["summary"] }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("accepts issueIdsOrKeys with exactly 100 items", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [] }), { status: 200 }) + ); + const keys100 = Array.from({ length: 100 }, (_, i) => `PROJ-${i}`); + const req = makeJiraProxyRequest({ + endpoint: "issue", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { issueIdsOrKeys: keys100, fields: ["summary"] }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + }); + + // ── SEAL_KEY_NEXT reseal ────────────────────────────────────────────────── + + it("includes resealed field when SEAL_KEY_NEXT is set", async () => { + // A different base64-encoded 32-byte key for rotation + const NEXT_SEAL_KEY = "bmV4dC1zZWFsLWtleS1mb3Itcm90YXRpb24hISE="; // "next-seal-key-for-rotation!!!" base64 + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 10, startAt: 0 }), { status: 200 }) + ); + + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = currentUser()", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv({ SEAL_KEY_NEXT: NEXT_SEAL_KEY })); + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(typeof json["resealed"]).toBe("string"); + expect((json["resealed"] as string).length).toBeGreaterThan(0); + }); + + // ── Validation gates ────────────────────────────────────────────────────── + + it("returns 403 when X-Requested-With header is missing", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = me", maxResults: 10 }, + }, { addXRequestedWith: false }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("missing_csrf_header"); + }); + + it("returns 403 when Origin header is wrong", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = me", maxResults: 10 }, + }, { origin: "https://evil.com" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + }); + + it("returns 401 when sealed token cannot be unsealed (wrong key or corrupted)", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: "corrupted-blob-cannot-unseal", + params: { jql: "assignee = me", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(401); + const json = await res.json() as Record; + expect(json["error"]).toBe("jira_proxy_error"); + }); + + it("accepts email of exactly 254 characters", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 10, startAt: 0 }), { status: 200 }) + ); + const email254 = "a".repeat(242) + "@example.com"; + expect(email254.length).toBe(254); + + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: email254, + sealed: sealedToken, + params: { jql: "assignee = currentUser()", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + }); + + it("rejects email of 255 characters", async () => { + globalThis.fetch = vi.fn(); + const email255 = "a".repeat(243) + "@example.com"; + expect(email255.length).toBe(255); + + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: email255, + sealed: sealedToken, + params: { jql: "assignee = me", maxResults: 10 }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("returns 429 when durable rate limiter denies", async () => { + globalThis.fetch = vi.fn(); + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = me", maxResults: 10 }, + }); + const env = makeEnv({ PROXY_RATE_LIMITER: { limit: vi.fn().mockResolvedValue({ success: false }) } }); + const res = await worker.fetch(req, env); + expect(res.status).toBe(429); + const json = await res.json() as Record; + expect(json["error"]).toBe("rate_limited"); + }); + + // ── SECURITY: console spy — no email or apiToken in logs ───────────────── + + it("does not log email or API token in any console output", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], total: 0, maxResults: 10, startAt: 0 }), { status: 200 }) + ); + + const req = makeJiraProxyRequest({ + endpoint: "search", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: { jql: "assignee = currentUser()", maxResults: 10 }, + }); + await worker.fetch(req, makeEnv()); + + // Collect all console calls across all levels + const allArgs: string[] = []; + for (const spy of [consoleSpy.info, consoleSpy.warn, consoleSpy.error]) { + for (const call of spy.mock.calls) { + allArgs.push(...call.map((arg: unknown) => String(arg))); + } + } + const allOutput = allArgs.join("\n"); + + expect(allOutput).not.toContain(TEST_EMAIL); + expect(allOutput).not.toContain(TEST_API_TOKEN); + }); + + it("does not log email or apiToken even on validation error paths", async () => { + globalThis.fetch = vi.fn(); + + // Trigger a validation error by using an invalid endpoint + const req = makeJiraProxyRequest({ + endpoint: "evil-endpoint", + cloudId: VALID_CLOUD_ID, + email: TEST_EMAIL, + sealed: sealedToken, + params: {}, + }); + await worker.fetch(req, makeEnv()); + + const allArgs: string[] = []; + for (const spy of [consoleSpy.info, consoleSpy.warn, consoleSpy.error]) { + for (const call of spy.mock.calls) { + allArgs.push(...call.map((arg: unknown) => String(arg))); + } + } + const allOutput = allArgs.join("\n"); + + expect(allOutput).not.toContain(TEST_EMAIL); + expect(allOutput).not.toContain(TEST_API_TOKEN); + }); + + // ── OPTIONS preflight ───────────────────────────────────────────────────── + + it("OPTIONS /api/jira/proxy with correct origin returns 204", async () => { + const req = new Request("https://gh.gordoncode.dev/api/jira/proxy", { + method: "OPTIONS", + headers: { + "Origin": ALLOWED_ORIGIN, + "CF-Connecting-IP": `10.3.0.${++_requestCounter}`, + }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(ALLOWED_ORIGIN); + }); +}); + +// /api/oauth/jira/resources endpoint removed — accessible-resources uses direct browser call + +// ── Jira Tenant Info (/api/jira/tenant-info) ────────────────────────────────── + +describe("POST /api/jira/tenant-info — Jira tenant info lookup", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + function makeTenantInfoRequest( + body: unknown, + options: { origin?: string; contentType?: string } = {} + ): Request { + return new Request("https://gh.gordoncode.dev/api/jira/tenant-info", { + method: "POST", + headers: { + "CF-Connecting-IP": `10.4.0.${++_requestCounter}`, + "Origin": options.origin ?? ALLOWED_ORIGIN, + "Content-Type": options.contentType ?? "application/json", + "X-Requested-With": "fetch", + }, + body: JSON.stringify(body), + }); + } + + it("returns cloudId for a valid atlassian.net siteUrl", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ cloudId: VALID_CLOUD_ID }), { status: 200 }) + ); + const req = makeTenantInfoRequest({ siteUrl: "https://mysite.atlassian.net" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(200); + const json = await res.json() as Record; + expect(json["cloudId"]).toBe(VALID_CLOUD_ID); + }); + + it("returns 400 for non-https siteUrl", async () => { + globalThis.fetch = vi.fn(); + const req = makeTenantInfoRequest({ siteUrl: "http://mysite.atlassian.net" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("returns 400 for non-atlassian hostname", async () => { + globalThis.fetch = vi.fn(); + const req = makeTenantInfoRequest({ siteUrl: "https://evil.example.com" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("returns 403 when X-Requested-With header is missing", async () => { + globalThis.fetch = vi.fn(); + const req = new Request("https://gh.gordoncode.dev/api/jira/tenant-info", { + method: "POST", + headers: { + "CF-Connecting-IP": `10.4.0.${++_requestCounter}`, + "Origin": ALLOWED_ORIGIN, + "Content-Type": "application/json", + }, + body: JSON.stringify({ siteUrl: "https://mysite.atlassian.net" }), + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + const json = await res.json() as Record; + expect(json["error"]).toBe("missing_csrf_header"); + }); + + it("returns 400 for hostname spoofing via subdomain trick", async () => { + globalThis.fetch = vi.fn(); + const req = makeTenantInfoRequest({ siteUrl: "https://evil.atlassian.net.attacker.com" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("returns 400 when siteUrl is missing from body", async () => { + globalThis.fetch = vi.fn(); + const req = makeTenantInfoRequest({}); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(400); + const json = await res.json() as Record; + expect(json["error"]).toBe("invalid_request"); + }); + + it("returns 403 when Origin header is wrong", async () => { + globalThis.fetch = vi.fn(); + const req = makeTenantInfoRequest( + { siteUrl: "https://mysite.atlassian.net" }, + { origin: "https://evil.com" } + ); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(403); + }); + + it("returns 502 when upstream tenant_info fetch throws", async () => { + globalThis.fetch = vi.fn().mockRejectedValueOnce(new Error("network error")); + const req = makeTenantInfoRequest({ siteUrl: "https://mysite.atlassian.net" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(502); + const json = await res.json() as Record; + expect(json["error"]).toBe("jira_tenant_info_failed"); + }); + + it("returns 502 when upstream returns non-2xx status", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Not Found" }), { status: 404 }) + ); + const req = makeTenantInfoRequest({ siteUrl: "https://mysite.atlassian.net" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(502); + const json = await res.json() as Record; + expect(json["error"]).toBe("jira_tenant_info_failed"); + }); + + it("returns 502 when upstream returns a non-UUID cloudId", async () => { + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ cloudId: "not-a-uuid" }), { status: 200 }) + ); + const req = makeTenantInfoRequest({ siteUrl: "https://mysite.atlassian.net" }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(502); + const json = await res.json() as Record; + expect(json["error"]).toBe("jira_tenant_info_failed"); + }); + + it("returns 429 after exceeding rate limit from same IP", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ cloudId: VALID_CLOUD_ID }), { status: 200 }) + ); + const fixedIp = "10.4.99.7"; + function makeFixedIpTenantRequest() { + return new Request("https://gh.gordoncode.dev/api/jira/tenant-info", { + method: "POST", + headers: { + "CF-Connecting-IP": fixedIp, + "Origin": ALLOWED_ORIGIN, + "Content-Type": "application/json", + "X-Requested-With": "fetch", + }, + body: JSON.stringify({ siteUrl: "https://mysite.atlassian.net" }), + }); + } + const env = makeEnv(); + for (let i = 0; i < 10; i++) { + await worker.fetch(makeFixedIpTenantRequest(), env); + } + const limited = await worker.fetch(makeFixedIpTenantRequest(), env); + expect(limited.status).toBe(429); + const json = await limited.json() as Record; + expect(json["error"]).toBe("rate_limited"); + }); + + it("OPTIONS /api/jira/tenant-info returns 204 with CORS headers", async () => { + const req = new Request("https://gh.gordoncode.dev/api/jira/tenant-info", { + method: "OPTIONS", + headers: { + "Origin": ALLOWED_ORIGIN, + "CF-Connecting-IP": `10.4.0.${++_requestCounter}`, + }, + }); + const res = await worker.fetch(req, makeEnv()); + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(ALLOWED_ORIGIN); + }); +}); diff --git a/tests/worker/oauth.test.ts b/tests/worker/oauth.test.ts index bc05a14c..87288561 100644 --- a/tests/worker/oauth.test.ts +++ b/tests/worker/oauth.test.ts @@ -428,7 +428,7 @@ describe("Worker OAuth endpoint", () => { const res = await worker.fetch(req, makeEnv()); expect(res.headers.get("Access-Control-Allow-Origin")).toBe(ALLOWED_ORIGIN); expect(res.headers.get("Access-Control-Allow-Methods")).toBe("POST"); - expect(res.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type"); + expect(res.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, X-Requested-With, cf-turnstile-response"); // No credentials header for OAuth App (no cookies) expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); }); diff --git a/tests/worker/seal.test.ts b/tests/worker/seal.test.ts index 11f1b477..edac864c 100644 --- a/tests/worker/seal.test.ts +++ b/tests/worker/seal.test.ts @@ -165,7 +165,7 @@ describe("Worker /api/proxy/seal endpoint", () => { expect(json["error"]).toBe("turnstile_failed"); }); - it("request with Turnstile response missing action field returns 403 with turnstile_failed", async () => { + it("request with Turnstile response missing action field passes verification (test keys)", async () => { globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true }), { status: 200 }) ); @@ -173,9 +173,8 @@ describe("Worker /api/proxy/seal endpoint", () => { const req = makeSealRequest(); const res = await worker.fetch(req, makeEnv()); - expect(res.status).toBe(403); - const json = await res.json() as Record; - expect(json["error"]).toBe("turnstile_failed"); + // Missing action is allowed (test keys don't return it) — request proceeds past Turnstile + expect(res.status).not.toBe(403); }); it("request with missing Turnstile token returns 403 with turnstile_failed", async () => { diff --git a/tests/worker/turnstile.test.ts b/tests/worker/turnstile.test.ts index bda3d806..c70b6a6c 100644 --- a/tests/worker/turnstile.test.ts +++ b/tests/worker/turnstile.test.ts @@ -62,7 +62,7 @@ describe("verifyTurnstile", () => { expect(result).toEqual({ success: false, errorCodes: ["action-mismatch"] }); }); - it("returns action-mismatch when expectedAction is provided but response action is missing", async () => { + it("succeeds when expectedAction is provided but response action is missing (test keys)", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ success: true }), { status: 200, @@ -70,6 +70,18 @@ describe("verifyTurnstile", () => { }) ); + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV, "seal"); + expect(result).toEqual({ success: true }); + }); + + it("returns action-mismatch when expectedAction differs from response action", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, action: "wrong-action" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + const result = await verifyTurnstile(TEST_TOKEN, TEST_IP, TEST_ENV, "seal"); expect(result).toEqual({ success: false, errorCodes: ["action-mismatch"] }); }); @@ -180,7 +192,7 @@ describe("verifyTurnstile", () => { expect(body.get("idempotency_key")).toBe("test-uuid-1234-5678-abcd-ef0123456789"); }); - it("uses redirect: error for SSRF hardening", async () => { + it("uses redirect: manual for SSRF hardening", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ success: true }), { status: 200, @@ -192,7 +204,7 @@ describe("verifyTurnstile", () => { const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]; expect(url).toBe("https://challenges.cloudflare.com/turnstile/v0/siteverify"); - expect(options.redirect).toBe("error"); + expect(options.redirect).toBe("manual"); expect(options.method).toBe("POST"); });