From 116335796761b73d3139458a21762eb44c04f28e Mon Sep 17 00:00:00 2001 From: Joshua Coles Date: Tue, 28 Jul 2026 14:21:25 +0000 Subject: [PATCH] Server core: config, GitHub GraphQL polling, SQLite persistence, timeline ingestion Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ --- .gitignore | 6 + .mise.toml | 2 + config.json | 37 + package.json | 33 + pnpm-lock.yaml | 1807 +++++++++++++++++++++++++++++++++++ server/categorize.ts | 94 ++ server/config.ts | 85 ++ server/db.ts | 257 +++++ server/github/client.ts | 58 ++ server/github/queries.ts | 143 +++ server/github/token.ts | 20 + server/poller.ts | 133 +++ server/reviewState.ts | 33 + server/scripts/sync-once.ts | 37 + server/timeline.ts | 91 ++ shared/types.ts | 83 ++ tsconfig.json | 14 + tsconfig.server.json | 8 + 18 files changed, 2941 insertions(+) create mode 100644 .gitignore create mode 100644 .mise.toml create mode 100644 config.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 server/categorize.ts create mode 100644 server/config.ts create mode 100644 server/db.ts create mode 100644 server/github/client.ts create mode 100644 server/github/queries.ts create mode 100644 server/github/token.ts create mode 100644 server/poller.ts create mode 100644 server/reviewState.ts create mode 100644 server/scripts/sync-once.ts create mode 100644 server/timeline.ts create mode 100644 shared/types.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.server.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f172f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +data/ +dist/ +dist-server/ +web/dist/ +*.log diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..6ea5a7e --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "24" diff --git a/config.json b/config.json new file mode 100644 index 0000000..b868f99 --- /dev/null +++ b/config.json @@ -0,0 +1,37 @@ +{ + "user": "joshuacoles", + "onlyInvolved": true, + "pollIntervalSeconds": 60, + "port": 4000, + "repos": [ + { + "name": "marketdojo/auction", + "checkCategories": { + "lint": [ + "rubocop", + "pronto/rubocop", + "frontend_lint", + "scss_modules", + "translation_lint", + "yamllint", + "brakeman", + "qlty check" + ], + "specs": ["specs", "rspec", "rstest", "coverage", "qlty coverage", "qlty coverage diff"], + "e2e": ["Preview E2E *"] + } + }, + { + "name": "marketdojo/auction-build", + "checkCategories": { + "lint": ["Format", "qlty check", "yamllint"] + } + }, + { + "name": "marketdojo/md_exts", + "checkCategories": { + "specs": ["tests"] + } + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3c06cb9 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "pr-monitor", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch server/index.ts", + "dev:web": "vite web", + "sync:once": "tsx server/scripts/sync-once.ts", + "build": "vite build web && tsc -p tsconfig.server.json", + "start": "node dist-server/server/index.js", + "typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p web/tsconfig.json --noEmit" + }, + "dependencies": { + "express": "^5.1.0", + "picomatch": "^4.0.2", + "zod": "^4.0.0" + }, + "devDependencies": { + "@tanstack/react-query": "^5.0.0", + "@types/express": "^5.0.0", + "@types/node": "^24.0.0", + "@types/picomatch": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsx": "^4.0.0", + "typescript": "^5.9.0", + "vite": "^7.0.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..5a5fcf2 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1807 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^5.1.0 + version: 5.2.1 + picomatch: + specifier: ^4.0.2 + version: 4.0.5 + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@tanstack/react-query': + specifier: ^5.0.0 + version: 5.101.4(react@19.2.8) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + '@types/picomatch': + specifier: ^4.0.0 + version: 4.0.3 + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^4.0.0 + version: 4.7.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.1)) + react: + specifier: ^19.0.0 + version: 19.2.8 + react-dom: + specifier: ^19.0.0 + version: 19.2.8(react@19.2.8) + tsx: + specifier: ^4.0.0 + version: 4.23.1 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vite: + specifier: ^7.0.0 + version: 7.3.6(@types/node@24.13.3)(tsx@4.23.1) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.3': + resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.3': + resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.3': + resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.3': + resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.3': + resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.3': + resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.3': + resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.3': + resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.3': + resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.3': + resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.3': + resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.3': + resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.3': + resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.3': + resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + cpu: [x64] + os: [win32] + + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.2': + resolution: {integrity: sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + baseline-browser-mapping@2.11.5: + resolution: {integrity: sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==} + engines: {node: '>=6.0.0'} + hasBin: true + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.397: + resolution: {integrity: sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + engines: {node: ^10 || ^12 || >=14} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + rollup@4.62.3: + resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.3': + optional: true + + '@rollup/rollup-android-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-x64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.3': + optional: true + + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-query@5.101.4(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.8 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.13.3 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.13.3 + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.2': + dependencies: + '@types/node': 24.13.3 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.2 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/picomatch@4.0.3': {} + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.13.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.13.3 + + '@vitejs/plugin-react@4.7.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.1))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.1) + transitivePeerDependencies: + - supports-color + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + baseline-browser-mapping@2.11.5: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.5 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.397 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001806: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.397: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + negotiator@1.0.0: {} + + node-releases@2.0.51: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.24: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react@19.2.8: {} + + rollup@4.62.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.3 + '@rollup/rollup-android-arm64': 4.62.3 + '@rollup/rollup-darwin-arm64': 4.62.3 + '@rollup/rollup-darwin-x64': 4.62.3 + '@rollup/rollup-freebsd-arm64': 4.62.3 + '@rollup/rollup-freebsd-x64': 4.62.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 + '@rollup/rollup-linux-arm-musleabihf': 4.62.3 + '@rollup/rollup-linux-arm64-gnu': 4.62.3 + '@rollup/rollup-linux-arm64-musl': 4.62.3 + '@rollup/rollup-linux-loong64-gnu': 4.62.3 + '@rollup/rollup-linux-loong64-musl': 4.62.3 + '@rollup/rollup-linux-ppc64-gnu': 4.62.3 + '@rollup/rollup-linux-ppc64-musl': 4.62.3 + '@rollup/rollup-linux-riscv64-gnu': 4.62.3 + '@rollup/rollup-linux-riscv64-musl': 4.62.3 + '@rollup/rollup-linux-s390x-gnu': 4.62.3 + '@rollup/rollup-linux-x64-gnu': 4.62.3 + '@rollup/rollup-linux-x64-musl': 4.62.3 + '@rollup/rollup-openbsd-x64': 4.62.3 + '@rollup/rollup-openharmony-arm64': 4.62.3 + '@rollup/rollup-win32-arm64-msvc': 4.62.3 + '@rollup/rollup-win32-ia32-msvc': 4.62.3 + '@rollup/rollup-win32-x64-gnu': 4.62.3 + '@rollup/rollup-win32-x64-msvc': 4.62.3 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + statuses@2.0.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + toidentifier@1.0.1: {} + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + vary@1.1.2: {} + + vite@7.3.6(@types/node@24.13.3)(tsx@4.23.1): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.24 + rollup: 4.62.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + tsx: 4.23.1 + + wrappy@1.0.2: {} + + yallist@3.1.1: {} + + zod@4.4.3: {} diff --git a/server/categorize.ts b/server/categorize.ts new file mode 100644 index 0000000..c1d97be --- /dev/null +++ b/server/categorize.ts @@ -0,0 +1,94 @@ +import type { + CategoryState, + CategoryStatus, + CheckCategory, + CheckInfo, +} from '../shared/types.js'; +import type { RepoRules } from './config.js'; +import type { CheckContext } from './github/queries.js'; + +const FAIL_CONCLUSIONS = new Set([ + 'FAILURE', + 'TIMED_OUT', + 'ACTION_REQUIRED', + 'CANCELLED', + 'STARTUP_FAILURE', +]); +const PENDING_STATUSES = new Set(['QUEUED', 'WAITING', 'PENDING', 'REQUESTED']); + +export function checkState(ctx: CheckContext): CategoryState { + if (ctx.__typename === 'StatusContext') { + if (ctx.state === 'SUCCESS') return 'pass'; + if (ctx.state === 'ERROR' || ctx.state === 'FAILURE') return 'fail'; + return 'pending'; // PENDING, EXPECTED + } + if (ctx.status === 'IN_PROGRESS') return 'running'; + if (PENDING_STATUSES.has(ctx.status)) return 'pending'; + // COMPLETED + if (ctx.conclusion === 'SKIPPED') return 'skipped'; + if (ctx.conclusion === 'SUCCESS' || ctx.conclusion === 'NEUTRAL') return 'pass'; + if (ctx.conclusion && FAIL_CONCLUSIONS.has(ctx.conclusion)) return 'fail'; + return 'pending'; // STALE or unknown +} + +export function categoryFor(rules: RepoRules | undefined, name: string): CheckCategory { + if (!rules) return 'other'; + const exact = rules.exact.get(name); + if (exact) return exact; + for (const glob of rules.globs) { + if (glob.isMatch(name)) return glob.category; + } + return 'other'; +} + +const AGGREGATE_ORDER: CategoryState[] = ['fail', 'running', 'pending', 'pass', 'skipped']; +const CATEGORIES: CheckCategory[] = ['lint', 'specs', 'e2e', 'other']; + +/** A commit accrues one CheckRun per workflow run, so re-runs and multi-trigger + * workflows duplicate names — keep only the latest run per name (like GitHub's UI). */ +function dedupeByName(contexts: CheckContext[]): CheckContext[] { + const byName = new Map(); + for (const ctx of contexts) { + const name = ctx.__typename === 'StatusContext' ? ctx.context : ctx.name; + const prev = byName.get(name); + if (!prev) { + byName.set(name, ctx); + continue; + } + const prevAt = prev.__typename === 'CheckRun' ? (prev.startedAt ?? '') : ''; + const curAt = ctx.__typename === 'CheckRun' ? (ctx.startedAt ?? '') : ''; + if (curAt >= prevAt) byName.set(name, ctx); + } + return [...byName.values()]; +} + +/** Group check contexts into lint/specs/e2e/other with aggregate state per category. + * Every check is kept — skipped placeholders included — so nothing is ever hidden. */ +export function categorize(rules: RepoRules | undefined, contexts: CheckContext[]): CategoryStatus[] { + const buckets = new Map(CATEGORIES.map((c) => [c, []])); + for (const ctx of dedupeByName(contexts)) { + const name = ctx.__typename === 'StatusContext' ? ctx.context : ctx.name; + buckets.get(categoryFor(rules, name))!.push({ + name, + state: checkState(ctx), + url: ctx.__typename === 'StatusContext' ? ctx.targetUrl : ctx.detailsUrl, + }); + } + + return CATEGORIES.map((category) => { + const checks = buckets.get(category)!; + checks.sort((a, b) => a.name.localeCompare(b.name)); + const active = checks.filter((c) => c.state !== 'skipped'); + let state: CategoryState = 'none'; + if (checks.length > 0) { + state = AGGREGATE_ORDER.find((s) => checks.some((c) => c.state === s)) ?? 'none'; + } + return { + category, + state, + passed: checks.filter((c) => c.state === 'pass').length, + total: active.length, + checks, + }; + }); +} diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..3bcb5ce --- /dev/null +++ b/server/config.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { z } from 'zod'; +import type { CheckCategory } from '../shared/types.js'; + +const categorySchema = z.array(z.string()); + +const repoSchema = z.object({ + name: z.string().regex(/^[\w.-]+\/[\w.-]+$/), + checkCategories: z + .object({ + lint: categorySchema.optional(), + specs: categorySchema.optional(), + e2e: categorySchema.optional(), + }) + .default({}), +}); + +const configSchema = z.object({ + user: z.string(), + onlyInvolved: z.boolean().default(true), + pollIntervalSeconds: z.number().int().min(15).default(60), + port: z.number().int().default(4000), + repos: z.array(repoSchema).min(1), +}); + +export type RawConfig = z.infer; + +interface GlobMatcher { + isMatch: (name: string) => boolean; + category: CheckCategory; +} + +export interface RepoRules { + exact: Map; + globs: GlobMatcher[]; +} + +export interface AppConfig extends RawConfig { + rulesByRepo: Map; + searchQuery: string; +} + +const GLOB_CHARS = /[*?]/; + +/** Check names are plain strings, not paths — `*` must match anything including `/` + * (e.g. "Preview E2E *" → "Preview E2E e2e/regression/srm"), and `${{ }}` in names + * must stay literal. So we compile a minimal glob ourselves instead of picomatch. */ +function compileNameGlob(pattern: string): (name: string) => boolean { + const regex = new RegExp( + `^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, + ); + return (name) => regex.test(name); +} + +export const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); + +export function loadConfig(path = join(rootDir, 'config.json')): AppConfig { + const raw = configSchema.parse(JSON.parse(readFileSync(path, 'utf8'))); + + const rulesByRepo = new Map(); + for (const repo of raw.repos) { + const exact = new Map(); + const globs: GlobMatcher[] = []; + for (const [category, patterns] of Object.entries(repo.checkCategories) as [ + CheckCategory, + string[], + ][]) { + for (const pattern of patterns ?? []) { + if (GLOB_CHARS.test(pattern)) { + globs.push({ isMatch: compileNameGlob(pattern), category }); + } else { + exact.set(pattern, category); + } + } + } + rulesByRepo.set(repo.name, { exact, globs }); + } + + const parts = ['is:pr', 'is:open', ...raw.repos.map((r) => `repo:${r.name}`)]; + if (raw.onlyInvolved) parts.push(`involves:${raw.user}`); + + return { ...raw, rulesByRepo, searchQuery: parts.join(' ') }; +} diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..ab42d78 --- /dev/null +++ b/server/db.ts @@ -0,0 +1,257 @@ +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { PrSnapshot, TimelineEvent, TimelineKind, TimelinePage } from '../shared/types.js'; +import { rootDir } from './config.js'; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS prs ( + id TEXT PRIMARY KEY, + repo TEXT NOT NULL, + number INTEGER NOT NULL, + state TEXT NOT NULL DEFAULT 'OPEN', + signature TEXT NOT NULL, + snapshot TEXT NOT NULL, + detail_synced_at TEXT, + updated_at TEXT NOT NULL, + UNIQUE (repo, number) +); + +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + pr_id TEXT NOT NULL REFERENCES prs(id), + kind TEXT NOT NULL, + actor TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + body_excerpt TEXT, + url TEXT NOT NULL, + review_state TEXT, + thread_id TEXT, + path TEXT, + read INTEGER NOT NULL DEFAULT 0, + read_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_events_created ON events (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_events_pr ON events (pr_id); +CREATE INDEX IF NOT EXISTS idx_events_unread ON events (read) WHERE read = 0; +`; + +export interface EventInsert { + id: string; + prId: string; + kind: TimelineKind; + actor: string; + createdAt: string; + updatedAt: string | null; + bodyExcerpt: string; + url: string; + reviewState: string | null; + threadId: string | null; + path: string | null; + read: boolean; +} + +export class Db { + private db: DatabaseSync; + + constructor(path?: string) { + if (!path) { + mkdirSync(join(rootDir, 'data'), { recursive: true }); + path = join(rootDir, 'data', 'pr-monitor.sqlite'); + } + this.db = new DatabaseSync(path); + this.db.exec('PRAGMA journal_mode = WAL'); + this.db.exec('PRAGMA foreign_keys = ON'); + this.db.exec(SCHEMA); + } + + getMeta(key: string): string | null { + const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key) as + | { value: string | null } + | undefined; + return row?.value ?? null; + } + + setMeta(key: string, value: string | null): void { + this.db + .prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ) + .run(key, value); + } + + getPrSignatures(): Map { + const rows = this.db + .prepare("SELECT id, signature, detail_synced_at FROM prs WHERE state = 'OPEN'") + .all() as { id: string; signature: string; detail_synced_at: string | null }[]; + return new Map(rows.map((r) => [r.id, { signature: r.signature, detailSyncedAt: r.detail_synced_at }])); + } + + upsertPr(snapshot: PrSnapshot, signature: string, detailSyncedAt: string | null): void { + this.db + .prepare( + `INSERT INTO prs (id, repo, number, state, signature, snapshot, detail_synced_at, updated_at) + VALUES (?, ?, ?, 'OPEN', ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + state = 'OPEN', signature = excluded.signature, snapshot = excluded.snapshot, + detail_synced_at = COALESCE(excluded.detail_synced_at, prs.detail_synced_at), + updated_at = excluded.updated_at`, + ) + .run( + snapshot.id, + snapshot.repo, + snapshot.number, + signature, + JSON.stringify(snapshot), + detailSyncedAt, + snapshot.updatedAt, + ); + } + + /** Update signature/snapshot from list data only (no detail fetch happened). */ + markGone(ids: string[]): void { + if (ids.length === 0) return; + const stmt = this.db.prepare("UPDATE prs SET state = 'GONE' WHERE id = ?"); + for (const id of ids) stmt.run(id); + } + + getOpenSnapshots(): PrSnapshot[] { + const rows = this.db + .prepare("SELECT snapshot FROM prs WHERE state = 'OPEN' ORDER BY updated_at DESC") + .all() as { snapshot: string }[]; + return rows.map((r) => JSON.parse(r.snapshot) as PrSnapshot); + } + + /** Insert or update an event. The conflict path never touches `read`. */ + upsertEvent(e: EventInsert): void { + this.db + .prepare( + `INSERT INTO events (id, pr_id, kind, actor, created_at, updated_at, body_excerpt, url, + review_state, thread_id, path, read, read_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) + ON CONFLICT(id) DO UPDATE SET + body_excerpt = excluded.body_excerpt, updated_at = excluded.updated_at`, + ) + .run( + e.id, + e.prId, + e.kind, + e.actor, + e.createdAt, + e.updatedAt, + e.bodyExcerpt, + e.url, + e.reviewState, + e.threadId, + e.path, + e.read ? 1 : 0, + ); + } + + getSnapshot(id: string): PrSnapshot | null { + const row = this.db.prepare('SELECT snapshot FROM prs WHERE id = ?').get(id) as + | { snapshot: string } + | undefined; + return row ? (JSON.parse(row.snapshot) as PrSnapshot) : null; + } + + hasEvent(id: string): boolean { + return this.db.prepare('SELECT 1 FROM events WHERE id = ?').get(id) !== undefined; + } + + timeline(opts: { limit: number; before?: string; unreadOnly?: boolean }): TimelinePage { + const clauses = ["p.state != ''"]; + const params: (string | number)[] = []; + if (opts.before) { + clauses.push('(e.created_at < ? OR (e.created_at = ? AND e.id < ?))'); + const [createdAt, id] = splitCursor(opts.before); + params.push(createdAt, createdAt, id); + } + if (opts.unreadOnly) clauses.push('e.read = 0'); + const rows = this.db + .prepare( + `SELECT e.*, p.repo, p.number AS pr_number, p.snapshot + FROM events e JOIN prs p ON p.id = e.pr_id + WHERE ${clauses.join(' AND ')} + ORDER BY e.created_at DESC, e.id DESC + LIMIT ?`, + ) + .all(...params, opts.limit + 1) as Record[]; + + const hasMore = rows.length > opts.limit; + const page = rows.slice(0, opts.limit); + const events: TimelineEvent[] = page.map((r) => ({ + id: r.id as string, + prId: r.pr_id as string, + repo: r.repo as string, + prNumber: r.pr_number as number, + prTitle: (JSON.parse(r.snapshot as string) as { title: string }).title, + kind: r.kind as TimelineEvent['kind'], + actor: r.actor as string, + createdAt: r.created_at as string, + bodyExcerpt: (r.body_excerpt as string) ?? '', + url: r.url as string, + reviewState: r.review_state as string | null, + path: r.path as string | null, + read: (r.read as number) === 1, + })); + const last = events[events.length - 1]; + return { events, nextCursor: hasMore && last ? joinCursor(last.createdAt, last.id) : null }; + } + + markRead(id: string, read: boolean, at: string): void { + this.db + .prepare('UPDATE events SET read = ?, read_at = ? WHERE id = ?') + .run(read ? 1 : 0, read ? at : null, id); + } + + markAllRead(at: string, before?: string): number { + const result = before + ? this.db + .prepare('UPDATE events SET read = 1, read_at = ? WHERE read = 0 AND created_at <= ?') + .run(at, before) + : this.db.prepare('UPDATE events SET read = 1, read_at = ? WHERE read = 0').run(at); + return Number(result.changes); + } + + markAllReadForPr(prId: string, at: string): number { + const result = this.db + .prepare('UPDATE events SET read = 1, read_at = ? WHERE read = 0 AND pr_id = ?') + .run(at, prId); + return Number(result.changes); + } + + unreadCounts(): { byPr: Record; total: number } { + const rows = this.db + .prepare( + `SELECT e.pr_id, COUNT(*) AS n FROM events e + JOIN prs p ON p.id = e.pr_id AND p.state = 'OPEN' + WHERE e.read = 0 GROUP BY e.pr_id`, + ) + .all() as { pr_id: string; n: number }[]; + const byPr: Record = {}; + let total = 0; + for (const r of rows) { + byPr[r.pr_id] = r.n; + total += r.n; + } + return { byPr, total }; + } +} + +export function joinCursor(createdAt: string, id: string): string { + return `${createdAt}|${id}`; +} + +function splitCursor(cursor: string): [string, string] { + const idx = cursor.indexOf('|'); + if (idx === -1) return [cursor, '']; + return [cursor.slice(0, idx), cursor.slice(idx + 1)]; +} diff --git a/server/github/client.ts b/server/github/client.ts new file mode 100644 index 0000000..cd559ce --- /dev/null +++ b/server/github/client.ts @@ -0,0 +1,58 @@ +import { getToken, invalidateToken } from './token.js'; + +export interface RateLimitInfo { + cost: number; + remaining: number; + resetAt: string; +} + +export class GithubError extends Error {} + +let lastRateLimit: RateLimitInfo | null = null; + +export function getLastRateLimit(): RateLimitInfo | null { + return lastRateLimit; +} + +export async function graphql( + query: string, + variables: Record, +): Promise { + for (let attempt = 0; ; attempt++) { + const res = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + authorization: `bearer ${getToken()}`, + 'content-type': 'application/json', + 'user-agent': 'pr-monitor', + }, + body: JSON.stringify({ query, variables }), + }); + + if (res.status === 401 && attempt === 0) { + invalidateToken(); + continue; + } + if (!res.ok) { + throw new GithubError(`GitHub API ${res.status}: ${(await res.text()).slice(0, 300)}`); + } + + const body = (await res.json()) as { + data?: T & { rateLimit?: RateLimitInfo }; + errors?: { message: string }[]; + }; + // Partial data with errors (e.g. one inaccessible node) is usable; only fail when + // there is no data at all. + if (!body.data) { + const msg = body.errors?.map((e) => e.message).join('; ') ?? 'no data returned'; + throw new GithubError(`GraphQL error: ${msg.slice(0, 500)}`); + } + if (body.data.rateLimit) { + lastRateLimit = body.data.rateLimit; + console.log( + `[github] cost=${lastRateLimit.cost} remaining=${lastRateLimit.remaining}`, + ); + } + return body.data; + } +} diff --git a/server/github/queries.ts b/server/github/queries.ts new file mode 100644 index 0000000..e4fe5aa --- /dev/null +++ b/server/github/queries.ts @@ -0,0 +1,143 @@ +export const LIST_QUERY = /* GraphQL */ ` + query List($q: String!, $cursor: String) { + rateLimit { cost remaining resetAt } + search(query: $q, type: ISSUE, first: 50, after: $cursor) { + issueCount + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequest { + id + number + title + url + isDraft + updatedAt + author { login } + headRefName + headRefOid + repository { nameWithOwner } + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } + reviewThreads(first: 50) { totalCount nodes { isResolved } } + comments(first: 1) { totalCount } + reviews(first: 1) { totalCount } + latestReviews(first: 10) { nodes { author { login } state submittedAt url } } + } + } + } + } +`; + +export interface ListPr { + id: string; + number: number; + title: string; + url: string; + isDraft: boolean; + updatedAt: string; + author: { login: string } | null; + headRefName: string; + headRefOid: string; + repository: { nameWithOwner: string }; + commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] }; + reviewThreads: { totalCount: number; nodes: { isResolved: boolean }[] }; + comments: { totalCount: number }; + reviews: { totalCount: number }; + latestReviews: { + nodes: { author: { login: string } | null; state: string; submittedAt: string; url: string }[]; + }; +} + +export interface ListResult { + search: { + issueCount: number; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: (ListPr | Record)[]; + }; +} + +export const DETAIL_QUERY = /* GraphQL */ ` + query Details($ids: [ID!]!) { + rateLimit { cost remaining resetAt } + nodes(ids: $ids) { + ... on PullRequest { + id + commits(last: 1) { + nodes { + commit { + oid + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { name status conclusion detailsUrl startedAt } + ... on StatusContext { context state targetUrl } + } + } + } + } + } + } + comments(last: 50) { + nodes { id author { login } bodyText createdAt updatedAt url } + } + reviews(last: 30) { + nodes { id author { login } state bodyText submittedAt url } + } + reviewThreads(first: 100) { + nodes { + id + isResolved + path + comments(first: 30) { + nodes { id author { login } bodyText createdAt updatedAt url } + } + } + } + } + } + } +`; + +export type CheckContext = + | { __typename: 'CheckRun'; name: string; status: string; conclusion: string | null; detailsUrl: string | null; startedAt: string | null } + | { __typename: 'StatusContext'; context: string; state: string; targetUrl: string | null }; + +export interface DetailComment { + id: string; + author: { login: string } | null; + bodyText: string; + createdAt: string; + updatedAt: string; + url: string; +} + +export interface DetailPr { + id: string; + commits: { + nodes: { + commit: { + oid: string; + statusCheckRollup: { state: string; contexts: { nodes: CheckContext[] } } | null; + }; + }[]; + }; + comments: { nodes: DetailComment[] }; + reviews: { + nodes: { + id: string; + author: { login: string } | null; + state: string; + bodyText: string; + submittedAt: string | null; + url: string; + }[]; + }; + reviewThreads: { + nodes: { id: string; isResolved: boolean; path: string | null; comments: { nodes: DetailComment[] } }[]; + }; +} + +export interface DetailResult { + nodes: (DetailPr | null)[]; +} diff --git a/server/github/token.ts b/server/github/token.ts new file mode 100644 index 0000000..41e6ec8 --- /dev/null +++ b/server/github/token.ts @@ -0,0 +1,20 @@ +import { execFileSync } from 'node:child_process'; + +let cached: string | null = null; + +export function getToken(): string { + if (cached) return cached; + const env = process.env.GITHUB_TOKEN; + if (env) { + cached = env; + return cached; + } + cached = execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim(); + if (!cached) throw new Error('gh auth token returned an empty token'); + return cached; +} + +/** Drop the cached token (called once on a 401 so the next call re-fetches). */ +export function invalidateToken(): void { + cached = null; +} diff --git a/server/poller.ts b/server/poller.ts new file mode 100644 index 0000000..daadbe6 --- /dev/null +++ b/server/poller.ts @@ -0,0 +1,133 @@ +import { createHash } from 'node:crypto'; +import type { PrSnapshot } from '../shared/types.js'; +import type { AppConfig } from './config.js'; +import type { Db } from './db.js'; +import { categorize } from './categorize.js'; +import { deriveReviewState, deriveVerdicts } from './reviewState.js'; +import { graphql } from './github/client.js'; +import { + DETAIL_QUERY, + LIST_QUERY, + type DetailPr, + type DetailResult, + type ListPr, + type ListResult, +} from './github/queries.js'; + +const DETAIL_BATCH = 10; +const ROLLING_REFRESH_MS = 15 * 60 * 1000; + +export interface PollResult { + changedPrIds: string[]; + newEventCount: number; + prCount: number; +} + +function signature(pr: ListPr): string { + const resolved = pr.reviewThreads.nodes.filter((t) => t.isResolved).length; + return createHash('sha1') + .update( + JSON.stringify([ + pr.updatedAt, + pr.headRefOid, + pr.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null, + pr.reviewThreads.totalCount, + resolved, + pr.comments.totalCount, + pr.reviews.totalCount, + pr.latestReviews.nodes.map((r) => `${r.author?.login}:${r.state}`), + ]), + ) + .digest('hex'); +} + +async function fetchList(config: AppConfig): Promise { + const prs: ListPr[] = []; + let cursor: string | null = null; + do { + const data: ListResult = await graphql(LIST_QUERY, { + q: config.searchQuery, + cursor, + }); + for (const node of data.search.nodes) { + if ('id' in node) prs.push(node as ListPr); + } + cursor = data.search.pageInfo.hasNextPage ? data.search.pageInfo.endCursor : null; + } while (cursor); + return prs; +} + +function buildSnapshot(config: AppConfig, pr: ListPr, detail: DetailPr | undefined, db: Db): PrSnapshot { + const repo = pr.repository.nameWithOwner; + let categories; + if (detail) { + const contexts = detail.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []; + categories = categorize(config.rulesByRepo.get(repo), contexts); + } else { + // Detail node inaccessible this round — keep the previous check breakdown. + categories = db.getSnapshot(pr.id)?.categories ?? categorize(config.rulesByRepo.get(repo), []); + } + return { + id: pr.id, + repo, + number: pr.number, + title: pr.title, + url: pr.url, + author: pr.author?.login ?? 'ghost', + isDraft: pr.isDraft, + branch: pr.headRefName, + updatedAt: pr.updatedAt, + ...deriveReviewState(pr), + verdicts: deriveVerdicts(pr), + categories, + }; +} + +export async function runPoll(config: AppConfig, db: Db): Promise { + const now = new Date().toISOString(); + const listPrs = await fetchList(config); + const known = db.getPrSignatures(); + + const changed: ListPr[] = []; + for (const pr of listPrs) { + const prev = known.get(pr.id); + const stale = + !prev?.detailSyncedAt || Date.now() - Date.parse(prev.detailSyncedAt) > ROLLING_REFRESH_MS; + if (!prev || prev.signature !== signature(pr) || stale) changed.push(pr); + } + + const present = new Set(listPrs.map((p) => p.id)); + db.markGone([...known.keys()].filter((id) => !present.has(id))); + + const detailById = new Map(); + for (let i = 0; i < changed.length; i += DETAIL_BATCH) { + const ids = changed.slice(i, i + DETAIL_BATCH).map((p) => p.id); + const data = await graphql(DETAIL_QUERY, { ids }); + for (const node of data.nodes) { + if (node?.id) detailById.set(node.id, node); + } + } + + const backfillAsRead = + db.getMeta('first_sync_done') !== '1' && process.env.BACKFILL_UNREAD !== '1'; + let newEventCount = 0; + for (const pr of changed) { + const detail = detailById.get(pr.id); + const snapshot = buildSnapshot(config, pr, detail, db); + db.upsertPr(snapshot, signature(pr), detail ? now : null); + if (detail) { + newEventCount += ingest(db, detail, config.user, backfillAsRead); + } + } + + db.setMeta('first_sync_done', '1'); + db.setMeta('last_sync_at', now); + db.setMeta('last_sync_error', null); + return { changedPrIds: changed.map((p) => p.id), newEventCount, prCount: listPrs.length }; +} + +// Thin wrapper so poller stays the single import for index.ts / sync-once. +import { ingestTimeline } from './timeline.js'; +function ingest(db: Db, detail: DetailPr, user: string, backfillAsRead: boolean): number { + return ingestTimeline(db, detail, { user, backfillAsRead }); +} diff --git a/server/reviewState.ts b/server/reviewState.ts new file mode 100644 index 0000000..f834481 --- /dev/null +++ b/server/reviewState.ts @@ -0,0 +1,33 @@ +import type { ReviewState, ReviewerVerdict } from '../shared/types.js'; +import type { ListPr } from './github/queries.js'; + +export function deriveReviewState(pr: ListPr): { + reviewState: ReviewState; + unresolvedThreads: number; + totalThreads: number; +} { + const totalThreads = pr.reviewThreads.totalCount; + const unresolvedThreads = pr.reviewThreads.nodes.filter((t) => !t.isResolved).length; + let reviewState: ReviewState; + if (pr.reviews.totalCount === 0 && totalThreads === 0) reviewState = 'no_review'; + else if (unresolvedThreads > 0) reviewState = 'outstanding'; + else reviewState = 'addressed'; + return { reviewState, unresolvedThreads, totalThreads }; +} + +export function deriveVerdicts(pr: ListPr): ReviewerVerdict[] { + const author = pr.author?.login; + return pr.latestReviews.nodes + .filter( + (r): r is typeof r & { state: 'APPROVED' | 'CHANGES_REQUESTED' } => + (r.state === 'APPROVED' || r.state === 'CHANGES_REQUESTED') && + r.author?.login !== undefined && + r.author.login !== author, + ) + .map((r) => ({ + reviewer: r.author!.login, + state: r.state, + url: r.url, + submittedAt: r.submittedAt, + })); +} diff --git a/server/scripts/sync-once.ts b/server/scripts/sync-once.ts new file mode 100644 index 0000000..929c660 --- /dev/null +++ b/server/scripts/sync-once.ts @@ -0,0 +1,37 @@ +import { loadConfig } from '../config.js'; +import { Db } from '../db.js'; +import { runPoll } from '../poller.js'; +import { getLastRateLimit } from '../github/client.js'; + +const config = loadConfig(); +const db = new Db(); + +console.log(`search: ${config.searchQuery}\n`); +const result = await runPoll(config, db); + +const stateIcon: Record = { + fail: '✗', + running: '◐', + pending: '·', + pass: '✓', + skipped: '–', + none: ' ', +}; + +for (const pr of db.getOpenSnapshots()) { + const cats = pr.categories + .map((c) => `${c.category}:${stateIcon[c.state]}${c.total ? ` ${c.passed}/${c.total}` : ''}`) + .join(' '); + console.log( + `${pr.repo}#${pr.number} [${pr.reviewState}${pr.unresolvedThreads ? ` ${pr.unresolvedThreads} open` : ''}] ${cats} ${pr.title.slice(0, 60)}`, + ); + const other = pr.categories.find((c) => c.category === 'other'); + if (other && other.checks.length > 0) { + console.log(` other checks: ${other.checks.map((c) => c.name).join(', ')}`); + } +} + +console.log( + `\n${result.prCount} PRs, ${result.changedPrIds.length} changed, ${result.newEventCount} new events`, +); +console.log(`rate limit: ${JSON.stringify(getLastRateLimit())}`); diff --git a/server/timeline.ts b/server/timeline.ts new file mode 100644 index 0000000..eacc086 --- /dev/null +++ b/server/timeline.ts @@ -0,0 +1,91 @@ +import type { Db, EventInsert } from './db.js'; +import type { DetailPr } from './github/queries.js'; + +const EXCERPT_LEN = 280; + +function excerpt(text: string): string { + const clean = text.replace(/\s+/g, ' ').trim(); + return clean.length > EXCERPT_LEN ? `${clean.slice(0, EXCERPT_LEN - 1)}…` : clean; +} + +export interface TimelineOpts { + user: string; + /** First-ever sync: mark the historical backfill as already read. */ + backfillAsRead: boolean; +} + +/** Extract timeline events from a PR detail node and upsert them. + * Returns the number of events that were new to the database. */ +export function ingestTimeline(db: Db, pr: DetailPr, opts: TimelineOpts): number { + const events: EventInsert[] = []; + + for (const c of pr.comments.nodes) { + events.push({ + id: c.id, + prId: pr.id, + kind: 'issue_comment', + actor: c.author?.login ?? 'ghost', + createdAt: c.createdAt, + updatedAt: c.updatedAt, + bodyExcerpt: excerpt(c.bodyText), + url: c.url, + reviewState: null, + threadId: null, + path: null, + read: false, + }); + } + + for (const thread of pr.reviewThreads.nodes) { + for (const c of thread.comments.nodes) { + events.push({ + id: c.id, + prId: pr.id, + kind: 'review_comment', + actor: c.author?.login ?? 'ghost', + createdAt: c.createdAt, + updatedAt: c.updatedAt, + bodyExcerpt: excerpt(c.bodyText), + url: c.url, + reviewState: null, + threadId: thread.id, + path: thread.path, + read: false, + }); + } + } + + for (const r of pr.reviews.nodes) { + if (r.state === 'PENDING') continue; + // Empty COMMENTED reviews are wrappers around inline comments — skip to avoid + // duplicating the thread items. + if (r.state === 'COMMENTED' && r.bodyText.trim() === '') continue; + events.push({ + id: r.id, + prId: pr.id, + kind: 'review', + actor: r.author?.login ?? 'ghost', + createdAt: r.submittedAt ?? '', + updatedAt: null, + bodyExcerpt: excerpt(r.bodyText), + url: r.url, + reviewState: r.state, + threadId: null, + path: null, + read: false, + }); + } + + let newCount = 0; + for (const e of events) { + if (!e.createdAt) continue; + if (db.hasEvent(e.id)) { + db.upsertEvent(e); // refresh excerpt/updated_at; read untouched + continue; + } + newCount++; + const read = opts.backfillAsRead || e.actor === opts.user; + db.upsertEvent({ ...e, read }); + } + return newCount; +} diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..ebe4f52 --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,83 @@ +export type CheckCategory = 'lint' | 'specs' | 'e2e' | 'other'; + +export type CategoryState = 'fail' | 'running' | 'pending' | 'pass' | 'skipped' | 'none'; + +export interface CheckInfo { + name: string; + state: CategoryState; + url: string | null; +} + +export interface CategoryStatus { + category: CheckCategory; + state: CategoryState; + passed: number; + /** Non-skipped check count (drives the "7/9" chip summary). */ + total: number; + checks: CheckInfo[]; +} + +export type ReviewState = 'no_review' | 'outstanding' | 'addressed'; + +export interface ReviewerVerdict { + reviewer: string; + state: 'APPROVED' | 'CHANGES_REQUESTED'; + url: string; + submittedAt: string; +} + +export interface PrSnapshot { + id: string; + repo: string; + number: number; + title: string; + url: string; + author: string; + isDraft: boolean; + branch: string; + updatedAt: string; + reviewState: ReviewState; + unresolvedThreads: number; + totalThreads: number; + verdicts: ReviewerVerdict[]; + categories: CategoryStatus[]; +} + +export type TimelineKind = 'issue_comment' | 'review_comment' | 'review'; + +export interface TimelineEvent { + id: string; + prId: string; + repo: string; + prNumber: number; + prTitle: string; + kind: TimelineKind; + actor: string; + createdAt: string; + bodyExcerpt: string; + url: string; + reviewState: string | null; + path: string | null; + read: boolean; +} + +export interface StatePayload { + prs: PrSnapshot[]; + lastSyncAt: string | null; + lastSyncError: string | null; + unreadCountsByPr: Record; + unreadTotal: number; +} + +export interface TimelinePage { + events: TimelineEvent[]; + nextCursor: string | null; +} + +export interface SyncEventPayload { + lastSyncAt: string | null; + error: string | null; + changedPrIds: string[]; + newEventCount: number; + unreadCount: number; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4616ebc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true + }, + "include": ["shared", "server"] +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..aeaa91d --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist-server", + "noEmit": false + }, + "include": ["shared", "server"] +}