diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..5319516b --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,18 @@ +name: Setup Node and pnpm +description: Install pnpm, Node.js, and workspace dependencies with frozen lockfile +inputs: + registry-url: + description: Optional npm registry URL for actions/setup-node (used by npm publish) + required: false + default: "" +runs: + using: composite + steps: + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 26.12.1 + cache: pnpm + registry-url: ${{ inputs.registry-url }} + - run: pnpm install --frozen-lockfile + shell: bash diff --git a/.github/actions/verify/action.yml b/.github/actions/verify/action.yml new file mode 100644 index 00000000..92a7d7f6 --- /dev/null +++ b/.github/actions/verify/action.yml @@ -0,0 +1,41 @@ +name: Verify +description: Setup toolchain and run CSP, typecheck, lint, unit, WAF smoke, and E2E checks +inputs: + vite-github-client-id: + description: VITE_GITHUB_CLIENT_ID for Playwright E2E tests + required: false + default: "" + deploy-domain: + description: Production domain for WAF smoke tests (skipped if empty) + required: false + default: "" +runs: + using: composite + steps: + - uses: ./.github/actions/setup + - name: Verify CSP inline script hash + shell: bash + run: bash scripts/verify-csp-hash.sh + - name: Typecheck + shell: bash + run: pnpm run typecheck + - name: Lint + shell: bash + run: pnpm lint + - name: Unit tests + shell: bash + run: pnpm test + - name: WAF smoke tests + if: inputs.deploy-domain != '' + shell: bash + run: pnpm test:waf "https://$DEPLOY_DOMAIN" + env: + DEPLOY_DOMAIN: ${{ inputs.deploy-domain }} + # No `playwright install` step: playwright.config.ts uses channel: "chrome", + # which resolves to ubuntu-latest's preinstalled Google Chrome — Playwright's + # own bundled Chromium download is never used and would be wasted work. + - name: Run E2E tests + shell: bash + run: pnpm test:e2e + env: + VITE_GITHUB_CLIENT_ID: ${{ inputs.vite-github-client-id }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cf02df9..4d031dc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,20 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: ./.github/actions/verify with: - node-version: 26.12.1 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Verify CSP inline script hash - run: bash scripts/verify-csp-hash.sh - - run: pnpm run typecheck - - run: pnpm test - # No `playwright install` step: playwright.config.ts uses channel: "chrome", - # which resolves to ubuntu-latest's preinstalled Google Chrome — Playwright's - # own bundled Chromium download is never used and would be wasted work. - - name: Run E2E tests - run: pnpm test:e2e - env: - VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }} + vite-github-client-id: ${{ vars.VITE_GITHUB_CLIENT_ID }} + deploy-domain: ${{ vars.DEPLOY_DOMAIN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2e601eeb..e763fc3e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,19 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: ./.github/actions/verify with: - node-version: 26.12.1 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run typecheck - - run: pnpm test - - name: Verify CSP inline script hash - run: bash scripts/verify-csp-hash.sh - - name: WAF smoke tests - if: vars.DEPLOY_DOMAIN != '' - run: pnpm test:waf "https://${{ vars.DEPLOY_DOMAIN }}" + vite-github-client-id: ${{ vars.VITE_GITHUB_CLIENT_ID }} + deploy-domain: ${{ vars.DEPLOY_DOMAIN }} - name: Validate deploy configuration run: pnpm validate:deploy env: diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index a5d98e7c..dc167935 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -10,12 +10,9 @@ jobs: id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: ./.github/actions/setup with: - node-version: 26.12.1 registry-url: "https://registry.npmjs.org" - - run: pnpm install --frozen-lockfile - run: pnpm --filter github-tracker-mcp run typecheck - run: pnpm --filter github-tracker-mcp run build - run: pnpm --filter github-tracker-mcp test diff --git a/.github/workflows/status-smoke.yml b/.github/workflows/status-smoke.yml index bb93b96d..5fb24879 100644 --- a/.github/workflows/status-smoke.yml +++ b/.github/workflows/status-smoke.yml @@ -10,10 +10,5 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 26.12.1 - cache: pnpm - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup - run: pnpm test:status-smoke diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..2afae691 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,83 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "options": { + "typeAware": true, + "denyWarnings": true, + "reportUnusedDisableDirectives": "error" + }, + "plugins": ["typescript", "unicorn", "oxc", "import", "promise", "vitest"], + "categories": { + "correctness": "error", + "suspicious": "warn", + "perf": "warn" + }, + "ignorePatterns": [ + "dist/**", + "mcp/dist/**", + "**/node_modules/**", + ".wrangler/**", + ".claude/**", + "hack/**", + "playwright-report/**", + "test-results/**" + ], + "rules": { + "typescript/consistent-type-imports": [ + "error", + { + "disallowTypeAnnotations": false + } + ], + "typescript/no-unsafe-type-assertion": "off", + "eslint/no-underscore-dangle": "off", + "unicorn/consistent-function-scoping": "off", + "import/no-unassigned-import": "off" + }, + "overrides": [ + { + "files": [ + "src/app/services/api.ts", + "src/app/services/events.ts", + "src/app/stores/cache.ts", + "src/app/components/settings/JiraFieldPicker.tsx" + ], + "rules": { + "eslint/no-await-in-loop": "off" + } + }, + { + "files": [ + "src/app/lib/mcp-relay.ts", + "src/app/lib/notifications.ts", + "src/app/lib/proxy.ts" + ], + "rules": { + "unicorn/prefer-add-event-listener": "off" + } + }, + { + "files": ["tests/**", "e2e/**", "mcp/tests/**"], + "plugins": ["typescript", "unicorn", "oxc", "import", "promise", "vitest"], + "rules": { + "eslint/no-await-in-loop": "off", + "unicorn/prefer-add-event-listener": "off", + "vitest/require-mock-type-parameters": "off", + "vitest/expect-expect": "off", + "typescript/unbound-method": "off", + "vitest/no-conditional-expect": "off" + } + }, + { + "files": ["tests/**/*.steps.tsx", "tests/worker/crypto.test.ts"], + "rules": { + "vitest/no-standalone-expect": "off" + } + }, + { + "files": ["tests/lib/settings-transfer.test.ts"], + "rules": { + "vitest/valid-expect": "off" + } + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 320c2130..5c531521 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,8 @@ pnpm test # unit tests (Vitest — root + mcp/) pnpm test:e2e # Playwright E2E tests (chromium) pnpm test:status-smoke # Live-network check that githubstatus.com still reports the tracked component names (also runs on a weekly schedule in CI) pnpm run typecheck # TypeScript validation (root + mcp/) +pnpm lint # Type-aware lint check (oxlint — root + mcp/) +pnpm lint:fix # Auto-fix safe lint findings (oxlint) pnpm run screenshot # Capture dashboard screenshot (saves to docs/) pnpm mcp:serve # Start the MCP server (requires GITHUB_TOKEN) ``` @@ -47,7 +49,7 @@ To test MCP tools interactively, use the MCP Inspector: npx @modelcontextprotocol/inspector tsx mcp/src/index.ts ``` -CI runs typecheck, unit tests, and E2E tests on every PR. Make sure they pass locally before pushing. +CI runs typecheck, lint (`oxlint`), unit tests, and E2E tests on every PR, and lint is also enforced pre-commit via `prek` alongside typecheck and unit tests. Make sure they pass locally before pushing. To run a specific test file: @@ -88,6 +90,8 @@ Branch from `main`. Use one of these prefixes: - `feat/` — new functionality - `fix/` — bug fixes +- `perf/` — performance changes +- `ci/` — CI/release workflow changes - `docs/` — documentation only - `refactor/` — code changes with no behavior change - `test/` — test additions or fixes @@ -99,7 +103,7 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/): type(scope): description ``` -Scope is optional. Use present-indicative mood: "adds feature", not "add feature" or "added feature". +Scope is optional. Use imperative mood: "add feature", not "adds feature" or "added feature". ## Releasing the MCP server @@ -145,6 +149,6 @@ git push upstream main --follow-tags All PRs target `main` on `gordon-code/github-tracker`. Keep PRs focused — one feature or fix per PR makes review faster and reverts cleaner. -In the PR body, describe what changed and why. CI runs typecheck, unit tests, and E2E tests automatically. PRs need a passing CI run before merge. +In the PR body, describe what changed and why. CI runs typecheck, lint, unit tests, and E2E tests automatically. PRs need a passing CI run before merge. When adding or changing user-facing features, update [docs/USER_GUIDE.md](docs/USER_GUIDE.md) to reflect the changes. diff --git a/README.md b/README.md index 365395e0..67db53bc 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ pnpm run dev # Start Vite dev server pnpm test # Run unit/component tests pnpm test:e2e # Run Playwright E2E tests pnpm run typecheck # TypeScript check +pnpm lint # Run type-aware linter (oxlint) pnpm run build # Production build pnpm run screenshot # Capture dashboard screenshot ``` diff --git a/mcp/src/octokit.ts b/mcp/src/octokit.ts index 6059ae2a..6f506818 100644 --- a/mcp/src/octokit.ts +++ b/mcp/src/octokit.ts @@ -106,7 +106,7 @@ export async function validateTokenScopes(): Promise { try { const response = await client.request("GET /user"); - const login = String((response.data as { login?: string }).login ?? "unknown"); + const login = (response.data as { login?: string }).login ?? "unknown"; const rawScopeHeader = (response.headers as Record)["x-oauth-scopes"]; if (rawScopeHeader === undefined) { diff --git a/mcp/src/resources.ts b/mcp/src/resources.ts index 4e363450..3577aad0 100644 --- a/mcp/src/resources.ts +++ b/mcp/src/resources.ts @@ -1,7 +1,7 @@ // ── MCP resource registration ───────────────────────────────────────────────── // Registers tracker:// resources with the MCP server. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { DataSource } from "./data-source.js"; // ── Resource registration ───────────────────────────────────────────────────── diff --git a/mcp/src/tools.ts b/mcp/src/tools.ts index eb2f7ec9..318b8659 100644 --- a/mcp/src/tools.ts +++ b/mcp/src/tools.ts @@ -1,7 +1,7 @@ // ── MCP tool registration ───────────────────────────────────────────────────── // Registers all 6 GitHub Tracker tools with the MCP server. -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { METHODS } from "../../src/shared/protocol.js"; import type { DataSource, CachedConfig } from "./data-source.js"; diff --git a/mcp/src/ws-relay.ts b/mcp/src/ws-relay.ts index 83845922..6f497f89 100644 --- a/mcp/src/ws-relay.ts +++ b/mcp/src/ws-relay.ts @@ -228,7 +228,7 @@ export function startWebSocketServer(): WebSocketServer | null { info: { origin: string; req: IncomingMessage; secure: boolean }, callback: (res: boolean, code?: number, message?: string) => void ): void { - const origin = info.req.headers.origin as string | undefined; + const origin = info.req.headers.origin; if (!isOriginAllowed(origin)) { console.error(`[mcp/ws] Rejected connection from disallowed origin: ${origin ?? "(none)"}`); callback(false, 403, "Origin not allowed"); diff --git a/mcp/tests/data-source.test.ts b/mcp/tests/data-source.test.ts index 8b1729cf..d78edbfd 100644 --- a/mcp/tests/data-source.test.ts +++ b/mcp/tests/data-source.test.ts @@ -15,7 +15,7 @@ import type { DataSource } from "../src/data-source.js"; // ── Mock ws-relay module ─────────────────────────────────────────────────────── // isRelayConnected is used by CompositeDataSource.tryBoth() let _mockIsConnected = false; -let _mockSendRequest: ReturnType; +let _mockSendRequest: ReturnType Promise>>; vi.mock("../src/ws-relay.js", () => ({ get isRelayConnected() { @@ -145,7 +145,7 @@ describe("OctokitDataSource", () => { pull_request: { merged_at: null }, }; - const responses = new Map([ + const responses = new Map([ ["GET /user", makeUserResponse()], ["GET /search/issues", makeSearchResponse([searchItem])], ]); @@ -177,7 +177,7 @@ describe("OctokitDataSource", () => { // no pull_request field }; - const responses = new Map([ + const responses = new Map([ ["GET /user", makeUserResponse()], ["GET /search/issues", makeSearchResponse([issueItem])], ]); @@ -192,7 +192,7 @@ describe("OctokitDataSource", () => { // Clear cached config to verify explicit param works without it setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true }); - const responses = new Map([ + const responses = new Map([ ["GET /user", makeUserResponse()], ["GET /search/issues", makeSearchResponse([])], ]); @@ -253,7 +253,7 @@ describe("OctokitDataSource", () => { }; const readyPR = { ...draftPR, id: 2, number: 2, draft: false, title: "Ready" }; - const responses = new Map([ + const responses = new Map([ ["GET /user", makeUserResponse()], ["GET /search/issues", makeSearchResponse([draftPR, readyPR])], ]); @@ -267,7 +267,7 @@ describe("OctokitDataSource", () => { it("filters by status=approved", async () => { // Note: REST search doesn't return reviewDecision, so approved filter returns empty - const responses = new Map([ + const responses = new Map([ ["GET /user", makeUserResponse()], ["GET /search/issues", makeSearchResponse([{ id: 1, number: 1, title: "Approved", state: "open", draft: false, @@ -302,7 +302,7 @@ describe("OctokitDataSource", () => { pull_request: { merged_at: null }, }; - const responses = new Map([ + const responses = new Map([ ["GET /user", makeUserResponse()], ["GET /search/issues", makeSearchResponse([prItem])], ]); @@ -341,7 +341,7 @@ describe("OctokitDataSource", () => { pull_request: { merged_at: null }, }; - const responses = new Map([ + const responses = new Map([ ["GET /user", makeUserResponse()], ["GET /search/issues", makeSearchResponse([prItem])], ]); @@ -377,7 +377,7 @@ describe("OctokitDataSource", () => { pull_request: { merged_at: null }, }; - const responses = new Map([ + const responses = new Map([ ["GET /user", makeUserResponse()], ["GET /search/issues", makeSearchResponse([issueItem, prItem])], ]); @@ -392,7 +392,7 @@ describe("OctokitDataSource", () => { }); it("passes explicit repo to search query", async () => { - const responses = new Map([ + const responses = new Map([ ["GET /user", makeUserResponse()], ["GET /search/issues", makeSearchResponse([])], ]); @@ -541,7 +541,7 @@ describe("OctokitDataSource", () => { }); it("constructs involves_me query with user login", async () => { - const requestMock = vi.fn().mockImplementation(async (route: string) => { + const requestMock = vi.fn(async (route: string, _params?: Record) => { if (route === "GET /user") return { data: { login: "testuser" }, headers: {} }; if (route === "GET /search/issues") return { data: { items: [], total_count: 0 }, headers: {} }; if (route === "GET /repos/{owner}/{repo}/actions/runs") return { data: { workflow_runs: [], total_count: 0 }, headers: {} }; @@ -552,17 +552,17 @@ describe("OctokitDataSource", () => { await ds.getDashboardSummary("involves_me"); const searchCalls = requestMock.mock.calls.filter( - ([route]: [string]) => route === "GET /search/issues" + ([route]) => route === "GET /search/issues" ); - const prCall = searchCalls.find(([, params]: [string, Record]) => - typeof params?.q === "string" && (params.q as string).includes("is:pr") + const prCall = searchCalls.find(([, params]) => + typeof params?.q === "string" && (params.q).includes("is:pr") ); expect(prCall).toBeDefined(); - expect(prCall![1].q).toContain("involves:testuser"); + expect(prCall![1]?.q).toContain("involves:testuser"); }); it("constructs all-scope query without involves filter", async () => { - const requestMock = vi.fn().mockImplementation(async (route: string) => { + const requestMock = vi.fn(async (route: string, _params?: Record) => { if (route === "GET /user") return { data: { login: "testuser" }, headers: {} }; if (route === "GET /search/issues") return { data: { items: [], total_count: 0 }, headers: {} }; if (route === "GET /repos/{owner}/{repo}/actions/runs") return { data: { workflow_runs: [], total_count: 0 }, headers: {} }; @@ -573,14 +573,14 @@ describe("OctokitDataSource", () => { await ds.getDashboardSummary("all"); const searchCalls = requestMock.mock.calls.filter( - ([route]: [string]) => route === "GET /search/issues" + ([route]) => route === "GET /search/issues" ); - const prCall = searchCalls.find(([, params]: [string, Record]) => - typeof params?.q === "string" && (params.q as string).includes("is:pr") && - !(params.q as string).includes("review-requested") + const prCall = searchCalls.find(([, params]) => + typeof params?.q === "string" && (params.q).includes("is:pr") && + !(params.q).includes("review-requested") ); expect(prCall).toBeDefined(); - expect(prCall![1].q).not.toContain("involves:"); + expect(prCall![1]?.q).not.toContain("involves:"); }); it("returns failingRunCount=null and skips actions API when enableActions is false", async () => { @@ -591,7 +591,7 @@ describe("OctokitDataSource", () => { monitoredRepos: [], enableActions: false, }); - const requestMock = vi.fn().mockImplementation(async (route: string) => { + const requestMock = vi.fn(async (route: string, _params?: Record) => { if (route === "GET /user") return { data: { login: "testuser" }, headers: {} }; if (route === "GET /search/issues") return { data: { items: [], total_count: 0 }, headers: {} }; throw new Error(`Unexpected: ${route}`); @@ -602,7 +602,7 @@ describe("OctokitDataSource", () => { expect(summary.failingRunCount).toBeNull(); const actionsCalls = requestMock.mock.calls.filter( - ([route]: [string]) => route === "GET /repos/{owner}/{repo}/actions/runs" + ([route]) => route === "GET /repos/{owner}/{repo}/actions/runs" ); expect(actionsCalls).toHaveLength(0); }); diff --git a/mcp/tests/integration.test.ts b/mcp/tests/integration.test.ts index e268962c..be98a3f6 100644 --- a/mcp/tests/integration.test.ts +++ b/mcp/tests/integration.test.ts @@ -14,12 +14,11 @@ import { import { WebSocketDataSource, CompositeDataSource, - OctokitDataSource, setCachedConfig, } from "../src/data-source.js"; import type { DataSource } from "../src/data-source.js"; import { METHODS, NOTIFICATIONS } from "../../src/shared/protocol.js"; -import { makeIssue, makePullRequest, makeWorkflowRun } from "../../tests/helpers/factories.js"; +import { makeIssue, makePullRequest } from "../../tests/helpers/factories.js"; // ── Helpers ──────────────────────────────────────────────────────────────────── @@ -69,7 +68,7 @@ function waitForClose(ws: WebSocket, timeout = 3000): Promise { function waitForMessage(ws: WebSocket, timeout = 3000): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error("Timeout waiting for message")), timeout); - ws.once("message", (data) => { clearTimeout(t); resolve(data.toString()); }); + ws.once("message", (data) => { clearTimeout(t); resolve((data as Buffer).toString()); }); }); } @@ -126,7 +125,7 @@ describe("Integration: WebSocket relay data flow", () => { // Client acts as SPA — responds to JSON-RPC requests client.on("message", (data) => { - const req = JSON.parse(data.toString()); + const req = JSON.parse((data as Buffer).toString()); if (req.method === METHODS.GET_RATE_LIMIT) { client!.send(JSON.stringify({ jsonrpc: "2.0", @@ -193,7 +192,7 @@ describe("Integration: WebSocket relay data flow", () => { const promise = sendRelayRequest(METHODS.GET_OPEN_PRS, {}); client.close(); - await expect(promise).rejects.toThrow(); + await expect(promise).rejects.toThrow("WebSocket relay disconnected"); expect(isRelayConnected()).toBe(false); }); @@ -231,7 +230,7 @@ describe("Integration: WebSocketDataSource through relay", () => { const mockPRs = [makePullRequest({ title: "Fix auth bug", repoFullName: "acme/app" })]; client.on("message", (data) => { - const req = JSON.parse(data.toString()); + const req = JSON.parse((data as Buffer).toString()); if (req.method === METHODS.GET_OPEN_PRS) { client!.send(JSON.stringify({ jsonrpc: "2.0", @@ -254,7 +253,7 @@ describe("Integration: WebSocketDataSource through relay", () => { await waitForOpen(client); client.on("message", (data) => { - const req = JSON.parse(data.toString()); + const req = JSON.parse((data as Buffer).toString()); if (req.method === METHODS.GET_CONFIG) { client!.send(JSON.stringify({ jsonrpc: "2.0", @@ -322,7 +321,7 @@ describe("Integration: CompositeDataSource fallback", () => { await waitForOpen(client); client.on("message", (data) => { - const req = JSON.parse(data.toString()); + const req = JSON.parse((data as Buffer).toString()); if (req.method === METHODS.GET_OPEN_ISSUES) { client.send(JSON.stringify({ jsonrpc: "2.0", diff --git a/mcp/tests/octokit.test.ts b/mcp/tests/octokit.test.ts index a0a6c356..82cbd9f5 100644 --- a/mcp/tests/octokit.test.ts +++ b/mcp/tests/octokit.test.ts @@ -14,7 +14,7 @@ describe("createOctokitClient — write guard hook", () => { let capturedMethod: string | undefined; client.hook.wrap("request", async (_request, options) => { capturedMethod = (options.method ?? "GET").toUpperCase(); - return { data: {}, headers: {}, status: 200, url: String(options.url) }; + return { data: {}, headers: {}, status: 200, url: options.url }; }); await client.request("GET /user"); @@ -27,8 +27,8 @@ describe("createOctokitClient — write guard hook", () => { let capturedUrl: string | undefined; client.hook.wrap("request", async (_request, options) => { - capturedUrl = String(options.url); - return { data: {}, headers: {}, status: 200, url: String(options.url) }; + capturedUrl = options.url; + return { data: {}, headers: {}, status: 200, url: options.url }; }); await client.request("POST /graphql", { query: "{ viewer { login } }" }); diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index 2bba516c..9526fd2e 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -7,9 +7,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerTools } from "../src/tools.js"; import type { DataSource } from "../src/data-source.js"; import type { - Issue, - PullRequest, - WorkflowRun, DashboardSummary, RateLimitInfo, } from "../../src/shared/types.js"; diff --git a/mcp/tests/ws-relay.test.ts b/mcp/tests/ws-relay.test.ts index d1882bd9..8f894a83 100644 --- a/mcp/tests/ws-relay.test.ts +++ b/mcp/tests/ws-relay.test.ts @@ -45,7 +45,7 @@ function waitForMessage(ws: WebSocket, timeout = 2000): Promise { const t = setTimeout(() => reject(new Error("Timeout waiting for message")), timeout); ws.once("message", (data) => { clearTimeout(t); - resolve(data.toString()); + resolve((data as Buffer).toString()); }); }); } @@ -103,8 +103,9 @@ describe("WebSocket relay server — connection", () => { beforeEach(async () => { process.env.MCP_WS_PORT = "0"; - wss = startWebSocketServer()!; - expect(wss).not.toBeNull(); + const started = startWebSocketServer(); + if (!started) throw new Error("Server not started"); + wss = started; await waitForEvent(wss, "listening"); port = getServerPort(wss); }); diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json index 6295d45f..de881d1d 100644 --- a/mcp/tsconfig.json +++ b/mcp/tsconfig.json @@ -7,6 +7,13 @@ "moduleResolution": "nodenext", "target": "ES2022", "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true, diff --git a/package.json b/package.json index 2cc58472..0d295b73 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "test": "vitest run --config vitest.workspace.ts", "test:watch": "vitest --config vitest.workspace.ts", "deploy": "wrangler deploy", - "typecheck": "tsc --build src/shared/tsconfig.json && tsc --noEmit && tsc --build mcp/tsconfig.json", + "typecheck": "tsc --build src/shared/tsconfig.json && tsc --build mcp/tsconfig.json && tsc -p tsconfig.lint.json", + "lint": "oxlint --type-aware --tsconfig tsconfig.lint.json --deny-warnings src tests e2e mcp/tests vite.config.ts vitest.config.ts vitest.smoke.config.ts vitest.workspace.ts playwright.config.ts playwright.config.screenshot.ts mcp/tsup.config.ts mcp/vitest.config.ts && oxlint --type-aware --tsconfig mcp/tsconfig.json --deny-warnings mcp/src", + "lint:fix": "oxlint --type-aware --tsconfig tsconfig.lint.json --fix src tests e2e mcp/tests vite.config.ts vitest.config.ts vitest.smoke.config.ts vitest.workspace.ts playwright.config.ts playwright.config.screenshot.ts mcp/tsup.config.ts mcp/vitest.config.ts && oxlint --type-aware --tsconfig mcp/tsconfig.json --fix mcp/src", "test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test", "test:waf": "bash scripts/waf-smoke-test.sh", "test:status-smoke": "vitest run --config vitest.smoke.config.ts", @@ -45,6 +47,8 @@ "daisyui": "5.7.47", "fake-indexeddb": "6.2.5", "happy-dom": "21.0.4", + "oxlint": "1.89.0", + "oxlint-tsgolint": "7.0.2004", "tailwindcss": "4.3.3", "typescript": "7.0.2", "vite": "8.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eb34045..5bd73179 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,12 @@ importers: happy-dom: specifier: 21.0.4 version: 21.0.4 + oxlint: + specifier: 1.89.0 + version: 1.89.0(oxlint-tsgolint@7.0.2004) + oxlint-tsgolint: + specifier: 7.0.2004 + version: 7.0.2004 tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -1403,6 +1409,158 @@ packages: '@oxc-project/types@0.155.0': resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + '@oxlint-tsgolint/darwin-arm64@7.0.2004': + resolution: {integrity: sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2004': + resolution: {integrity: sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2004': + resolution: {integrity: sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2004': + resolution: {integrity: sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2004': + resolution: {integrity: sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2004': + resolution: {integrity: sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.89.0': + resolution: {integrity: sha512-Pflpba5F3gRAQ12sWglRxvWTHEdAusNDvmi4Y5N1T5SY1AGkM7AixOnET8qkrnLJ/F32qAlNokO6eCknT8tmnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.89.0': + resolution: {integrity: sha512-dYmyD1x7LaPlb79vZWUcpTShk8dI9z1I+IubtqrBxWNJaDhQdPHwcsvg7q5StTU4xWyd9AwPfLaTYFZY8NZgtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.89.0': + resolution: {integrity: sha512-Z/oxOPiIqiS7fItRdxaaOEC+6GD3OpGi0IxoR0dMefzQHolsKLzScw2DNcu/5gUPok3tv+G9mslvYB5bo3i0qg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.89.0': + resolution: {integrity: sha512-eAjTkHSjAxrdCPJxO6y5sPQL5pYLKb2kvxZz9UU7tXnOZk9I/HLtkvx9S7GHY883k+AmL/eC7mqFt+w7WCwk0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.89.0': + resolution: {integrity: sha512-0lNCA9Tl1n1FKpqy2/9ckp0kIuSrtJ/l9bMRgaEhOtM7TsOHshFuwOcvW8oA9teXLMOJT61LjEKWiaHDFGxF/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.89.0': + resolution: {integrity: sha512-OTFEvi9W+JczaZD1Caj17SGCoW8H7TE+fvx0D7ATEWa1Lohn91UMxFQ6r145SiGy/yCxeIwptMys/RAM7gSVSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.89.0': + resolution: {integrity: sha512-fRxgNHvBLswj6MrhW2uBq5UDhPRQlCbHeq/Nf8yuG9zOxhDsXqX/mi2PX1axgsQxMm88el7HLiJAzglAnflYxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.89.0': + resolution: {integrity: sha512-v9ENzHvwrQoYSWTtviH1pOYc7R9UovDxVofnck50cNmPihRRTcrg5X0UlRbLOZ7IVtxPOlh2hb7kDO+FpE8SfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.89.0': + resolution: {integrity: sha512-vfffe8mQ484AzVadlUOFpgL+JDwhvs9hrzxTgQP1FVfi4ZEUUYeffXlzC9dFV7Dgnu5s/KrNKvfmG6HxBd/ISQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.89.0': + resolution: {integrity: sha512-Buonlp4hVS7RGNP5M7bPpGNQrenyzA2C64JcO/oz9C1c6dGOXJ7VEAnaiAMY7voou0JYyCJdvDVlFQc5DZX3DQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.89.0': + resolution: {integrity: sha512-1eVwJJrJ3lYIPT+cm7BcBBZZXY6IQZP/oyonaruTor/MrVyWOpvnwt1oD5CLdsHg5aVBIxXodQSvt+1gK6ck5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.89.0': + resolution: {integrity: sha512-fRQQsKUGShdEL5Fl9o9uhj3grkZbeXKLv0icQ5tspUCNYSDy8wzzEcP1WC2bKdTQ0rSszbKlO3iiHORxV22DLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.89.0': + resolution: {integrity: sha512-dEtYjIy6+fHcjKklsmtmMkZRhwHr2UbzjmpvuPtQYSeN9awQ2hx5JLms5Wvri+8P/NZtD8v5hfgHALUql+/Ycg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.89.0': + resolution: {integrity: sha512-hqjwIj5KOYM8Wjef/p7IruNi1iByLhSzbMBk62y22P/E8q7Jj0Pp+DeUTKTRYyZYCDtU1wEGlnWOnttHbyNIww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.89.0': + resolution: {integrity: sha512-iAQN3e6o9UgUCcdGwqRx/jYcKbG3HK7ur3rH6lTqVcQeNGZ85AeWIg6cgn/edwFQ0ofEZZItMwkzyCVTahnvEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.89.0': + resolution: {integrity: sha512-MNxjEByz3N/7Q4qFQuPWvc14TpN9LoD3pECEDsJTZbP/wyB7U+hduUXEl7QAbbgVezFwsb9sWtNUO6XVBndOhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.89.0': + resolution: {integrity: sha512-xxe+i9wfuFso/VnoL4mfj0UlzE+cfCFiUB06vkXCMyQdgVVmItF5FfAF4tATAktSU6uHruWR+FjqvAT9cHZZzg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.89.0': + resolution: {integrity: sha512-saFy07cO94E664glwgLmKLI40HgTL1PY86y4T6+ud4niX4H0Kdqjcmqmjps7mQNdBytV5guGbXDdyQtRfeLbLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.89.0': + resolution: {integrity: sha512-hLFJ/o0f/dTJh/EW0vmcbREYSQO9tYgfiAAlsOkiLFiJuRRWvYtFELUXN5o2ijpSo/tlO1n+YlWN58tZwPG18A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@playwright/test@1.63.0': resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} engines: {node: '>=20'} @@ -2776,6 +2934,23 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oxlint-tsgolint@7.0.2004: + resolution: {integrity: sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==} + hasBin: true + + oxlint@1.89.0: + resolution: {integrity: sha512-a25hkfo61okGPvJz432fRIPoxlKtNnOVy9w4rzrmGgNm4iVTPwpSQlmdqNWnp6p9QyyPVfAZRgnf5XvSBZFk/A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -4144,6 +4319,81 @@ snapshots: '@oxc-project/types@0.155.0': {} + '@oxlint-tsgolint/darwin-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2004': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2004': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2004': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2004': + optional: true + + '@oxlint/binding-android-arm-eabi@1.89.0': + optional: true + + '@oxlint/binding-android-arm64@1.89.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.89.0': + optional: true + + '@oxlint/binding-darwin-x64@1.89.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.89.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.89.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.89.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.89.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.89.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.89.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.89.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.89.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.89.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.89.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.89.0': + optional: true + '@playwright/test@1.63.0': dependencies: playwright: 1.63.0 @@ -5323,6 +5573,38 @@ snapshots: dependencies: wrappy: 1.0.2 + oxlint-tsgolint@7.0.2004: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2004 + '@oxlint-tsgolint/darwin-x64': 7.0.2004 + '@oxlint-tsgolint/linux-arm64': 7.0.2004 + '@oxlint-tsgolint/linux-x64': 7.0.2004 + '@oxlint-tsgolint/win32-arm64': 7.0.2004 + '@oxlint-tsgolint/win32-x64': 7.0.2004 + + oxlint@1.89.0(oxlint-tsgolint@7.0.2004): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.89.0 + '@oxlint/binding-android-arm64': 1.89.0 + '@oxlint/binding-darwin-arm64': 1.89.0 + '@oxlint/binding-darwin-x64': 1.89.0 + '@oxlint/binding-freebsd-x64': 1.89.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.89.0 + '@oxlint/binding-linux-arm-musleabihf': 1.89.0 + '@oxlint/binding-linux-arm64-gnu': 1.89.0 + '@oxlint/binding-linux-arm64-musl': 1.89.0 + '@oxlint/binding-linux-ppc64-gnu': 1.89.0 + '@oxlint/binding-linux-riscv64-gnu': 1.89.0 + '@oxlint/binding-linux-riscv64-musl': 1.89.0 + '@oxlint/binding-linux-s390x-gnu': 1.89.0 + '@oxlint/binding-linux-x64-gnu': 1.89.0 + '@oxlint/binding-linux-x64-musl': 1.89.0 + '@oxlint/binding-openharmony-arm64': 1.89.0 + '@oxlint/binding-win32-arm64-msvc': 1.89.0 + '@oxlint/binding-win32-ia32-msvc': 1.89.0 + '@oxlint/binding-win32-x64-msvc': 1.89.0 + oxlint-tsgolint: 7.0.2004 + parse5@7.3.0: dependencies: entities: 6.0.1 diff --git a/prek.toml b/prek.toml index 7448d0b1..25974beb 100644 --- a/prek.toml +++ b/prek.toml @@ -31,6 +31,15 @@ pass_filenames = false always_run = true priority = 0 +[[repos.hooks]] +id = "lint" +name = "oxlint" +language = "system" +entry = "pnpm lint" +pass_filenames = false +always_run = true +priority = 0 + [[repos.hooks]] id = "test" name = "Vitest tests" diff --git a/renovate.json b/renovate.json index 1f7c3e71..5f1b4487 100644 --- a/renovate.json +++ b/renovate.json @@ -2,5 +2,11 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "description": [ "Managed by khepri-deps/renovate — global config provides weekly Monday schedule, 14-day release cooldown, and vulnerability alert bypass." + ], + "packageRules": [ + { + "matchPackageNames": ["oxlint", "oxlint-tsgolint"], + "groupName": "oxlint" + } ] } diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 87d96642..cc8da239 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -90,7 +90,7 @@ function groupRuns(runs: WorkflowRun[]): RepoGroup[] { } function sortWorkflowsByStatus(workflows: WorkflowGroup[]): WorkflowGroup[] { - return [...workflows].sort((a, b) => { + return workflows.toSorted((a, b) => { const priorityOf = (wf: WorkflowGroup): number => { const latest = wf.runs[0]; if (!latest) return 2; @@ -336,7 +336,7 @@ export default function ActionsTab(props: ActionsTabProps) { {(wfGroup) => { const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; - const isWfExpanded = () => !!expandedWorkflows[wfKey]; + const isWfExpanded = () => expandedWorkflows[wfKey] ?? false; return (
diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 2345a7c1..b04ae70a 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1,7 +1,7 @@ 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"; +import TabBar, { type TabId } from "../layout/TabBar"; import FilterBar from "../layout/FilterBar"; import ActionsTab from "./ActionsTab"; import IssuesTab from "./IssuesTab"; @@ -157,7 +157,7 @@ function loadDepMetaCache(): Map { const map = new Map(); for (const [k, v] of Object.entries(parsed)) { const id = Number(k); - if (!isNaN(id) && v && typeof v === "object") map.set(id, v as VersionInfo); + if (!isNaN(id) && v && typeof v === "object") map.set(id, v); } return map; } catch { @@ -429,7 +429,7 @@ async function pollFetch(): Promise { } } })); - resolvedPullRequests = unwrap(dashboardData.pullRequests).map((pr) => ({ ...pr })); + resolvedPullRequests = unwrap(dashboardData.pullRequests).map((pr) => Object.assign({}, pr)); } else { // Phase 1 did NOT fire (cached data existed or subsequent poll). // Full atomic replacement — all fields (light + heavy) may have @@ -723,7 +723,8 @@ export default function DashboardPage() { } function fetchAndSetRlDetail(): void { - void fetchRateLimitDetails().then((detail) => { + void (async () => { + const detail = await fetchRateLimitDetails(); if (!detail) { setRlDetail("Failed to load"); return; @@ -734,11 +735,10 @@ export default function DashboardPage() { `GraphQL: ${detail.graphql.remaining.toLocaleString()}/${detail.graphql.limit.toLocaleString()} remaining\n` + `Resets: ${resetTime}` ); - }); + })(); } if (import.meta.env.DEV) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__debug = { forceHotPoll: () => { const allPrIds = new Set(dashboardData.pullRequests.map(pr => pr.id)); @@ -958,18 +958,21 @@ export default function DashboardPage() { // Only adds orgs to the filter list — repos are user-selected via Settings. const client = getClient(); if (client && config.onboardingComplete) { - void fetchOrgs(client).then((allOrgs) => { - const currentSet = new Set(config.selectedOrgs.map((o) => o.toLowerCase())); - const newOrgs = allOrgs - .map((o) => o.login) - .filter((login) => !currentSet.has(login.toLowerCase())); - if (newOrgs.length > 0) { - setConfig("selectedOrgs", [...config.selectedOrgs, ...newOrgs]); - console.info(`[dashboard] auto-synced ${newOrgs.length} new org(s)`); + void (async () => { + try { + const allOrgs = await fetchOrgs(client); + const currentSet = new Set(config.selectedOrgs.map((o) => o.toLowerCase())); + const newOrgs = allOrgs + .map((o) => o.login) + .filter((login) => !currentSet.has(login.toLowerCase())); + if (newOrgs.length > 0) { + setConfig("selectedOrgs", [...config.selectedOrgs, ...newOrgs]); + console.info(`[dashboard] auto-synced ${newOrgs.length} new org(s)`); + } + } catch { + // Non-fatal — org sync failure doesn't block dashboard } - }).catch(() => { - // Non-fatal — org sync failure doesn't block dashboard - }); + })(); } // Backfill config.jira.siteUrl from auth state if missing (handles configs @@ -1294,10 +1297,15 @@ export default function DashboardPage() { ...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); + void (async () => { + try { + const map = await detectAndLookupJiraKeys(items, client); + if (import.meta.env.DEV) console.info("[jira] key detection:", map.size, "keys found", [...map.keys()].slice(0, 10)); + setJiraKeyMap(map); + } catch (err) { + handleJiraError(err); + } + })(); })); // Re-fetch Jira assigned issues when Jira auth transitions false -> true @@ -1353,10 +1361,10 @@ export default function DashboardPage() { try { const dashboardIssues = findDashboardIssues(dashboardData.issues, trackedBotLogins()); const activeNodeIds = new Set(dashboardIssues.map((di) => di.nodeId)); - for (const k of [..._dashboardBodyCache.keys()]) { + for (const k of _dashboardBodyCache.keys()) { if (!activeNodeIds.has(k)) _dashboardBodyCache.delete(k); } - for (const k of [..._dashboardBodyFailureCooldown.keys()]) { + for (const k of _dashboardBodyFailureCooldown.keys()) { if (!activeNodeIds.has(k)) _dashboardBodyFailureCooldown.delete(k); } @@ -1445,7 +1453,7 @@ export default function DashboardPage() { // PR happens to trigger a fetch would incorrectly stay excluded on a stale // cooldown entry for up to DEP_BODY_FAILURE_COOLDOWN_MS. const depPrIds = new Set(depPrs.map((pr) => pr.id)); - for (const k of [..._depBodyFailureCooldown.keys()]) { + for (const k of _depBodyFailureCooldown.keys()) { if (!depPrIds.has(k)) _depBodyFailureCooldown.delete(k); } @@ -1488,7 +1496,7 @@ export default function DashboardPage() { if (parsed) merged.set(id, parsed); } // Prune entries for PRs no longer in the dependency set - for (const k of [...merged.keys()]) { + for (const k of merged.keys()) { if (!depPrIds.has(k)) merged.delete(k); } setDepMeta(merged); @@ -1529,8 +1537,9 @@ export default function DashboardPage() { if (Array.isArray(errObj.errors)) { for (const item of errObj.errors) { if (item && typeof item === "object") { - const type = (item as Record).type; - const msg = String((item as Record).message ?? ""); + const itemObj = item as Record; + const type = itemObj.type; + const msg = typeof itemObj.message === "string" ? itemObj.message : ""; if ( type === "FORBIDDEN" || type === "INSUFFICIENT_SCOPES" || @@ -1552,7 +1561,7 @@ export default function DashboardPage() { function isStaleCommitError(err: unknown): boolean { if (!err || typeof err !== "object") return false; const errObj = err as Record; - const msg = err instanceof Error ? err.message : String(errObj.message ?? ""); + const msg = err instanceof Error ? err.message : typeof errObj.message === "string" ? errObj.message : ""; return /commitOID|was not part of the pull request|pull request head|outdated|end of the branch/i.test(msg); } @@ -1745,8 +1754,8 @@ export default function DashboardPage() { counts={tabCounts()} enableTracking={config.enableTracking} enableActions={config.enableActions} - enableJira={!!config.jira?.enabled} - jiraExpired={Boolean(config.jira?.enabled && !isJiraAuthenticated())} + enableJira={config.jira?.enabled ?? false} + jiraExpired={(config.jira?.enabled ?? false) && !isJiraAuthenticated()} enableDependencies={enableDependencies()} customTabs={config.customTabs.filter((t) => config.enableActions || t.baseType !== "actions").map((t) => ({ id: t.id, name: t.name, isUnscoped: isTabUnscoped(t) }))} onAddTab={() => setShowCustomTabModal(true)} diff --git a/src/app/components/dashboard/DependenciesTab.tsx b/src/app/components/dashboard/DependenciesTab.tsx index 6ff6227a..31d4f190 100644 --- a/src/app/components/dashboard/DependenciesTab.tsx +++ b/src/app/components/dashboard/DependenciesTab.tsx @@ -142,11 +142,11 @@ export default function DependenciesTab(props: DependenciesTabProps) { const activeFilters = createMemo(() => ({ ...DEP_FILTER_DEFAULTS, - ...(viewState.tabFilters.dependencies ?? {}), + ...viewState.tabFilters.dependencies, })); const botOptions = createMemo(() => { - const logins = [...new Set(props.pullRequests.map((pr) => pr.userLogin))].sort(); + const logins = [...new Set(props.pullRequests.map((pr) => pr.userLogin))].toSorted(); return { label: "Bot", field: "bot", diff --git a/src/app/components/dashboard/IgnoreBadge.tsx b/src/app/components/dashboard/IgnoreBadge.tsx index ecf3acbd..d93951db 100644 --- a/src/app/components/dashboard/IgnoreBadge.tsx +++ b/src/app/components/dashboard/IgnoreBadge.tsx @@ -15,6 +15,7 @@ function typeIcon(type: IgnoredItem["type"]): string { case "pullRequest": return "⌥"; case "workflowRun": + default: return "▶"; } } diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 5eb2626e..05cf28b1 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -133,7 +133,7 @@ export default function IssuesTab(props: IssuesTabProps) { if (effectiveScope === "involves_me" && !isInvolvedItem(issue)) return false; if (tabFilter.role !== "all") { - if (!roles.includes(tabFilter.role as "author" | "assignee")) return false; + if (!roles.includes(tabFilter.role)) return false; } if (tabFilter.comments !== "all") { @@ -157,7 +157,7 @@ export default function IssuesTab(props: IssuesTabProps) { }); const { field, direction } = viewState.globalSort; - items = [...items].sort((a, b) => { + items = items.toSorted((a, b) => { let cmp = 0; switch (field as SortField) { case "repo": diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 424b9340..810f5182 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, JSX, on, onCleanup, Show } from "solid-js"; +import { createEffect, createMemo, createSignal, For, type JSX, on, onCleanup, Show } from "solid-js"; import { config } from "../../stores/config"; import { isSafeGitHubUrl } from "../../lib/url"; import { relativeTime, shortRelativeTime, formatCount } from "../../lib/format"; diff --git a/src/app/components/dashboard/JiraAssignedTab.tsx b/src/app/components/dashboard/JiraAssignedTab.tsx index b3d74f9d..bc8d82c3 100644 --- a/src/app/components/dashboard/JiraAssignedTab.tsx +++ b/src/app/components/dashboard/JiraAssignedTab.tsx @@ -202,7 +202,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { }); } - const expandByDefault = () => !!(config.jira?.expandIssueDetails); + const expandByDefault = () => config.jira?.expandIssueDetails ?? false; const pinnedJiraKeys = createMemo(() => new Set( @@ -256,14 +256,14 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { break; } case "updated": { - const aUp = String(a.fields.updated ?? ""); - const bUp = String(b.fields.updated ?? ""); + const aUp = a.fields.updated ?? ""; + const bUp = 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 ?? ""); + const aCr = a.fields.created ?? ""; + const bCr = b.fields.created ?? ""; cmp = aCr < bCr ? -1 : aCr > bCr ? 1 : 0; break; } @@ -289,7 +289,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { const result = filteredSorted().map((issue): JiraItem => { const cached = itemsWithGroupKeyCache.get(issue.key); if (cached && cached.source === issue) return cached.wrapped; - const wrapped: JiraItem = { ...issue, repoFullName: issue.fields.project?.key ?? "OTHER" }; + const wrapped: JiraItem = Object.assign({}, issue, { repoFullName: issue.fields.project?.key ?? "OTHER" }); itemsWithGroupKeyCache.set(issue.key, { source: issue, wrapped }); return wrapped; }); @@ -474,7 +474,7 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) { fallback={} > {type().name}; for (const key of ["displayName", "name", "value", "label"]) { - if (typeof obj[key] === "string") return obj[key] as string; + if (typeof obj[key] === "string") return obj[key]; } } const json = JSON.stringify(value); diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 51518605..434e5b27 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -180,7 +180,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const isEnriched = pr.enriched !== false; if (tabFilters.role !== "all") { // Role depends on assigneeLogins/reviewerLogins (heavy), but "author" is light - if (isEnriched && !roles.includes(tabFilters.role as "author" | "reviewer" | "assignee")) return false; + if (isEnriched && !roles.includes(tabFilters.role)) return false; if (!isEnriched && tabFilters.role === "author" && !roles.includes("author")) return false; } if (tabFilters.reviewDecision !== "all") { @@ -223,7 +223,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { }); const { field, direction } = viewState.globalSort; - items = [...items].sort((a, b) => { + items = items.toSorted((a, b) => { let cmp = 0; switch (field as SortField) { case "repo": @@ -620,10 +620,10 @@ 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) => ({ + const annotated = [...new Set([...titleKeys, ...branchKeys])].map((key): { key: string; source: "title" | "branch" | "title & branch" } => ({ key, - source: (titleKeys.has(key) && branchKeys.has(key) ? "title & branch" - : titleKeys.has(key) ? "title" : "branch") as "title" | "branch" | "title & branch", + source: titleKeys.has(key) && branchKeys.has(key) ? "title & branch" + : titleKeys.has(key) ? "title" : "branch", })); return ( diff --git a/src/app/components/dashboard/TrackedTab.tsx b/src/app/components/dashboard/TrackedTab.tsx index eb3063ee..9571272d 100644 --- a/src/app/components/dashboard/TrackedTab.tsx +++ b/src/app/components/dashboard/TrackedTab.tsx @@ -212,7 +212,7 @@ export default function TrackedTab(props: TrackedTabProps) { {item.title} - +
{item.repoFullName}{" "} diff --git a/src/app/components/onboarding/OrgSelector.tsx b/src/app/components/onboarding/OrgSelector.tsx index 9d8850f4..74878f01 100644 --- a/src/app/components/onboarding/OrgSelector.tsx +++ b/src/app/components/onboarding/OrgSelector.tsx @@ -1,5 +1,5 @@ import { createSignal, createResource, createEffect, untrack, For, Show } from "solid-js"; -import { fetchOrgs, OrgEntry } from "../../services/api"; +import { fetchOrgs, type OrgEntry } from "../../services/api"; import { getClient } from "../../services/github"; import { pushNotification } from "../../lib/errors"; import LoadingSpinner from "../shared/LoadingSpinner"; diff --git a/src/app/components/onboarding/RepoSelector.tsx b/src/app/components/onboarding/RepoSelector.tsx index 42e99fce..0b9ab121 100644 --- a/src/app/components/onboarding/RepoSelector.tsx +++ b/src/app/components/onboarding/RepoSelector.tsx @@ -7,7 +7,7 @@ import { Index, For, } from "solid-js"; -import { fetchOrgs, fetchRepos, discoverUpstreamRepos, OrgEntry, RepoRef, RepoEntry } from "../../services/api"; +import { fetchOrgs, fetchRepos, discoverUpstreamRepos, type OrgEntry, type RepoRef, type RepoEntry } from "../../services/api"; import { REPO_CAP } from "../../../shared/types"; import { getClient } from "../../services/github"; import { user } from "../../stores/auth"; @@ -291,18 +291,18 @@ export default function RepoSelector(props: RepoSelectorProps) { }); const ownedOrgs = new Set(orgs.map((o) => o.toLowerCase())); ownedOrgs.add(currentUser.login.toLowerCase()); - void discoverUpstreamRepos(discoveryClient, currentUser.login, allOrgFullNames, props.trackedUsers) - .then((repos) => { + void (async () => { + try { + const repos = await discoverUpstreamRepos(discoveryClient, currentUser.login, allOrgFullNames, props.trackedUsers); if (version !== effectVersion) return; setDiscoveredRepos(repos.filter((r) => !ownedOrgs.has(r.owner.toLowerCase()))); setDiscoveryCapped(repos.length >= 100); - }) - .catch(() => { + } catch { // Non-fatal — partial results may already be in state - }) - .finally(() => { + } finally { if (version === effectVersion) setDiscoveringUpstream(false); - }); + } + })(); } } })(); @@ -323,16 +323,16 @@ export default function RepoSelector(props: RepoSelectorProps) { const state = orgStates().find((s) => s.org === org); const type = state?.type ?? "org"; - void fetchRepos(client, org, type) - .then((repos) => { + void (async () => { + try { + const repos = await fetchRepos(client, org, type); if (version !== effectVersion) return; setOrgStates((prev) => prev.map((s) => s.org === org ? { ...s, repos, loading: false, error: null } : s ) ); - }) - .catch((err) => { + } catch (err) { if (version !== effectVersion) return; const message = err instanceof Error ? err.message : "Failed to load repositories"; @@ -341,7 +341,8 @@ export default function RepoSelector(props: RepoSelectorProps) { s.org === org ? { ...s, loading: false, error: message } : s ) ); - }); + } + })(); } // ── Selection helpers ────────────────────────────────────────────────────── @@ -362,7 +363,7 @@ export default function RepoSelector(props: RepoSelectorProps) { const states = orgStates(); // Invalidate frozen order when org membership changes (key is order-independent). - const currentKey = [...props.selectedOrgs].sort().join(","); + const currentKey = props.selectedOrgs.toSorted().join(","); if (currentKey !== frozenOrgsKey) { frozenOrder = null; frozenOrgsKey = currentKey; @@ -393,7 +394,7 @@ export default function RepoSelector(props: RepoSelectorProps) { if (states.some((s) => !selectedOrgSet.has(s.org))) return states; // Sort: personal org first, then alphabetically. Capture order to freeze it. - const sorted = [...states].sort((a, b) => { + const sorted = states.toSorted((a, b) => { const aIsUser = a.type === "user" ? 0 : 1; const bIsUser = b.type === "user" ? 0 : 1; if (aIsUser !== bIsUser) return aIsUser - bIsUser; diff --git a/src/app/components/settings/JiraFieldPicker.tsx b/src/app/components/settings/JiraFieldPicker.tsx index 5f0dbe5e..39b0c32d 100644 --- a/src/app/components/settings/JiraFieldPicker.tsx +++ b/src/app/components/settings/JiraFieldPicker.tsx @@ -43,11 +43,11 @@ export default function JiraFieldPicker(props: JiraFieldPickerProps) { try { const result = await props.client.searchJql(jql, { maxResults: 20, fields: ["*all"] }); for (const issue of result.issues) { - const fields = issue.fields as Record; + const issueFields = issue.fields as Record; for (const id of fieldIdSet) { if (id in merged) continue; - if (fields[id] !== undefined && fields[id] !== null) { - merged[id] = fields[id]; + if (issueFields[id] !== undefined && issueFields[id] !== null) { + merged[id] = issueFields[id]; } } } @@ -73,7 +73,7 @@ export default function JiraFieldPicker(props: JiraFieldPickerProps) { const samples = sampleValues(); const sel = selected(); const selOrder = [...sel.keys()]; - return matched.sort((a, b) => { + return matched.toSorted((a, b) => { const aSel = sel.has(a.id); const bSel = sel.has(b.id); if (aSel !== bSel) return aSel ? -1 : 1; diff --git a/src/app/components/settings/Section.tsx b/src/app/components/settings/Section.tsx index 6046153c..3b7ab0bc 100644 --- a/src/app/components/settings/Section.tsx +++ b/src/app/components/settings/Section.tsx @@ -1,4 +1,4 @@ -import { JSX, Show } from "solid-js"; +import { type JSX, Show } from "solid-js"; export default function Section(props: { id?: string; title: string; description?: string; children: JSX.Element }) { return ( diff --git a/src/app/components/settings/SettingRow.tsx b/src/app/components/settings/SettingRow.tsx index 400a176b..3956442e 100644 --- a/src/app/components/settings/SettingRow.tsx +++ b/src/app/components/settings/SettingRow.tsx @@ -1,4 +1,4 @@ -import { JSX, Show } from "solid-js"; +import { type JSX, Show } from "solid-js"; export default function SettingRow(props: { label: string; diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 6e082876..dcf59b31 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -587,7 +587,7 @@ export default function SettingsPage() { function handleJiraDeepLink() { if (typeof window === "undefined" || window.location.hash !== "#jira") return; const shouldOpenReplace = - config.jira?.authMethod === "token" && (isJiraAuthenticated() || Boolean(config.jira?.enabled)); + config.jira?.authMethod === "token" && (isJiraAuthenticated() || config.jira.enabled); if (shouldOpenReplace) { openReplaceJiraTokenPanel(false, true); } @@ -671,9 +671,9 @@ export default function SettingsPage() { async function handleJiraApiTokenConnect() { const email = jiraApiEmail().trim(); - const token = jiraApiToken().trim(); + const apiToken = jiraApiToken().trim(); const siteUrl = jiraApiSiteUrl(); - if (!email || !token || !siteUrl) { + if (!email || !apiToken || !siteUrl) { setJiraApiError("Email, API token, and site name are all required."); return; } @@ -697,7 +697,7 @@ export default function SettingsPage() { return; } - const sealedToken = await sealApiToken(token, "jira-api-token"); + const sealedToken = await sealApiToken(apiToken, "jira-api-token"); // Validate by making a search request through the proxy const resp = await fetch("/api/jira/proxy", { method: "POST", @@ -1640,7 +1640,7 @@ export default function SettingsPage() { {config.jira?.authMethod === "token" ? "API Token" : "OAuth"}