From b2b81f324f7e535c06ab1d8c5df0021b166171be Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 24 Apr 2026 17:46:49 +0000 Subject: [PATCH 01/18] feat: add config-driven GitHub issues/PRs/CI dashboard --- .github/dashboard/README.md | 87 ++++ .github/dashboard/package-lock.json | 569 +++++++++++++++++++++++ .github/dashboard/package.json | 12 + .github/dashboard/src/compute.ts | 678 ++++++++++++++++++++++++++++ .github/dashboard/src/config.ts | 141 ++++++ .github/dashboard/src/generate.ts | 45 ++ .github/dashboard/src/github.ts | 131 ++++++ .github/dashboard/src/render.ts | 356 +++++++++++++++ .github/dashboard/src/types.ts | 238 ++++++++++ .github/dashboard/tsconfig.json | 13 + .github/workflows/dashboard.yml | 49 ++ 11 files changed, 2319 insertions(+) create mode 100644 .github/dashboard/README.md create mode 100644 .github/dashboard/package-lock.json create mode 100644 .github/dashboard/package.json create mode 100644 .github/dashboard/src/compute.ts create mode 100644 .github/dashboard/src/config.ts create mode 100644 .github/dashboard/src/generate.ts create mode 100644 .github/dashboard/src/github.ts create mode 100644 .github/dashboard/src/render.ts create mode 100644 .github/dashboard/src/types.ts create mode 100644 .github/dashboard/tsconfig.json create mode 100644 .github/workflows/dashboard.yml diff --git a/.github/dashboard/README.md b/.github/dashboard/README.md new file mode 100644 index 00000000..84b20e37 --- /dev/null +++ b/.github/dashboard/README.md @@ -0,0 +1,87 @@ +# GitHub Dashboard + +Config-driven static dashboard for tracking issues, PRs, and CI health. Auto-deployed to GitHub Pages via Actions. + +## Quick Start + +```bash +cd .github/dashboard +npm install +npx tsx src/generate.ts # fetches live data, outputs to site/ +npx serve site -l 8901 # preview at http://localhost:8901 +``` + +## Project Structure + +``` +src/ + config.ts ← declarative dashboard config (pages, sections, metrics) + types.ts ← all TypeScript types + github.ts ← GitHub API client (REST + GraphQL) + compute.ts ← transforms raw data → chart-ready data + render.ts ← generates HTML with Chart.js + generate.ts ← entry point: fetch → compute → render → write +site/ ← generated output (gitignored) +``` + +## Adding a Section + +Edit `src/config.ts`. Each page has a `sections` array. Available section types: + +| Type | Description | Key fields | +| --------------- | ----------------------------------- | -------------------------------- | +| `stats` | Stat cards with time windows | `metrics`, `windows` | +| `timeline` | Weekly bar chart + cumulative line | `series` | +| `distribution` | Doughnut or bar chart | `field`, `chart` | +| `histogram` | Bucketed bar chart | `field`, `buckets`, `groupBy` | +| `table` | Filtered/sorted table | `filter`, `columns`, `limit` | +| `termFrequency` | Word frequency from titles | `field`, `filter`, `minCount` | +| `ci` | CI pass rates, failures, flaky jobs | `workflows`, `branch`, `maxRuns` | + +Example — add a new chart to the PRs page: + +```typescript +{ type: "distribution", field: "author", chart: "bar", orientation: "horizontal" }, +``` + +## Adding a Page + +Add an entry to `config.pages` in `src/config.ts`: + +```typescript +{ + id: "my-page", + title: "My Page", + dataSource: "issues", // "issues" | "prs" | "ci" + sections: [ ... ], +} +``` + +Navigation links are generated automatically. + +## Development Workflow + +```bash +# Type check +npx tsc --noEmit + +# Generate with reduced CI data (faster iteration) +# Set maxRuns to 10 in config.ts, then: +npx tsx src/generate.ts + +# Serve and preview +npx serve site -l 8901 +``` + +Full generation takes ~90s (CI job fetching is the bottleneck). For faster iteration, set `maxRuns: 10` in the CI +section config while developing. + +## Deployment + +Handled automatically by `.github/workflows/dashboard.yml`: + +- Triggers on issue/PR events, daily at 6am UTC, or manual dispatch +- Runs `npm ci && npx tsx src/generate.ts` +- Deploys `site/` to GitHub Pages + +To enable: Settings → Pages → Source → GitHub Actions. diff --git a/.github/dashboard/package-lock.json b/.github/dashboard/package-lock.json new file mode 100644 index 00000000..d0b81063 --- /dev/null +++ b/.github/dashboard/package-lock.json @@ -0,0 +1,569 @@ +{ + "name": "dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "tsx": "^4.19.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/.github/dashboard/package.json b/.github/dashboard/package.json new file mode 100644 index 00000000..06a1fb1c --- /dev/null +++ b/.github/dashboard/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "type": "module", + "scripts": { + "generate": "tsx src/generate.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "tsx": "^4.19.0", + "typescript": "^5.5.0" + } +} diff --git a/.github/dashboard/src/compute.ts b/.github/dashboard/src/compute.ts new file mode 100644 index 00000000..67b11c9f --- /dev/null +++ b/.github/dashboard/src/compute.ts @@ -0,0 +1,678 @@ +import type { + CIData, + ChartData, + GHIssue, + GHPullRequestNode, + HistogramBucket, + Issue, + PageConfig, + PageData, + PullRequest, + SectionData, + StatValue, + TableRow, + TableSection, + TermCount, + WeekBucket, + WorkflowRun, +} from './types.js'; + +const MS_PER_HOUR = 3_600_000; +const MS_PER_DAY = 86_400_000; + +function formatHours(h: number): string { + if (h < 1) return `${Math.round(h * 60)}m`; + if (h < 24) return `${h.toFixed(1)}h`; + return `${(h / 24).toFixed(1)}d`; +} + +function percentiles(vals: number[]): { median: number; avg: number; p90: number } { + if (vals.length === 0) return { median: 0, avg: 0, p90: 0 }; + const sorted = [...vals].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)] ?? 0; + const avg = sorted.reduce((s, v) => s + v, 0) / sorted.length; + const p90 = sorted[Math.floor(sorted.length * 0.9)] ?? sorted[sorted.length - 1] ?? 0; + return { median, avg, p90 }; +} + +function weekKey(d: Date): string { + const day = d.getDay(); + const monday = new Date(d); + monday.setDate(d.getDate() - ((day + 6) % 7)); + return monday.toISOString().slice(0, 10); +} + +function ageDays(created: Date): number { + return (Date.now() - created.getTime()) / MS_PER_DAY; +} + +function formatRelativeTime(d: Date): string { + const diffMs = Date.now() - d.getTime(); + const hours = diffMs / MS_PER_HOUR; + if (hours < 1) return `${Math.round(hours * 60)}m ago`; + if (hours < 24) return `${Math.round(hours)}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +function isIssue(item: Issue | PullRequest): item is Issue { + return 'stateReason' in item; +} + +// ── Parsers ───────────────────────────────────────────────────────── + +export function parseIssues(raw: GHIssue[]): Issue[] { + return raw + .filter(r => !r.pull_request) + .map(r => ({ + number: r.number, + title: r.title, + state: r.state.toLowerCase() as 'open' | 'closed', + created: new Date(r.created_at), + closed: r.closed_at ? new Date(r.closed_at) : null, + labels: r.labels.map(l => l.name), + assignees: r.assignees.map(a => a.login), + comments: r.comments, + reactions: r.reactions.total_count, + stateReason: r.state_reason, + author: r.user.login, + authorType: r.author_association, + })); +} + +export function parsePRs(raw: GHPullRequestNode[]): PullRequest[] { + return raw.map(r => { + const created = new Date(r.createdAt); + const sortedReviews = r.reviews.nodes + .filter(rv => rv.submittedAt) + .sort((a, b) => new Date(a.submittedAt!).getTime() - new Date(b.submittedAt!).getTime()); + const firstReview = sortedReviews[0]; + const lastReview = sortedReviews[sortedReviews.length - 1]; + const ttfrHours = firstReview?.submittedAt + ? (new Date(firstReview.submittedAt).getTime() - created.getTime()) / MS_PER_HOUR + : null; + const ttmHours = r.mergedAt ? (new Date(r.mergedAt).getTime() - created.getTime()) / MS_PER_HOUR : null; + const lastCommitNode = r.commits.nodes[0]; + const lastCommitDate = lastCommitNode ? new Date(lastCommitNode.commit.committedDate) : null; + const lastReviewDate = lastReview?.submittedAt ? new Date(lastReview.submittedAt) : null; + const priorityRank: Record = { P0: 0, P1: 1, P2: 2, P3: 3, bug: 4, enhancement: 5 }; + let linkedIssuePriority: string | null = null; + let bestRank = Infinity; + for (const issue of r.closingIssuesReferences.nodes) { + for (const l of issue.labels.nodes) { + for (const [prefix, rank] of Object.entries(priorityRank)) { + if (l.name.startsWith(prefix) && rank < bestRank) { + linkedIssuePriority = l.name; + bestRank = rank; + } + } + } + } + const allApproved = + r.reviews.nodes.filter(rv => rv.submittedAt).length > 0 && + r.reviews.nodes.filter(rv => rv.submittedAt).every(rv => rv.state === 'APPROVED'); + const state: 'open' | 'closed' = r.state === 'OPEN' ? 'open' : 'closed'; + let bucket: PullRequest['bucket']; + if (state === 'closed') { + bucket = 'closed'; + } else if (!lastReviewDate) { + bucket = 'needs-initial-review'; + } else if (lastCommitDate && lastCommitDate > lastReviewDate) { + bucket = 'needs-re-review'; + } else if (allApproved) { + bucket = 'approved'; + } else { + bucket = 'waiting-on-author'; + } + return { + number: r.number, + title: r.title, + state, + created, + merged: r.mergedAt ? new Date(r.mergedAt) : null, + draft: r.isDraft, + author: r.author?.login ?? 'ghost', + labels: r.labels.nodes.map(l => l.name), + ttfrHours, + ttmHours, + lastCommitDate, + lastReviewDate, + linkedIssuePriority, + bucket, + }; + }); +} + +// ── Stats ─────────────────────────────────────────────────────────── + +function computeStats(metrics: string[], items: (Issue | PullRequest)[]): StatValue[] { + const issues = items.filter(isIssue); + const prs = items.filter((i): i is PullRequest => !isIssue(i)); + + const resolutionHours = issues + .filter(i => i.closed) + .map(i => (i.closed!.getTime() - i.created.getTime()) / MS_PER_HOUR); + const resPct = percentiles(resolutionHours); + + const ttfrVals = prs.map(p => p.ttfrHours).filter((v): v is number => v !== null); + const ttmVals = prs.map(p => p.ttmHours).filter((v): v is number => v !== null); + const ttfrPct = percentiles(ttfrVals); + const ttmPct = percentiles(ttmVals); + + const fourWeeksAgo = Date.now() - 28 * MS_PER_DAY; + const recentClosed = issues.filter(i => i.closed && i.closed.getTime() > fourWeeksAgo).length; + + const lookup: Record StatValue> = { + total: () => ({ key: 'Total', value: items.length }), + open: () => ({ key: 'Open', value: items.filter(i => i.state === 'open').length, color: 'green' }), + closed: () => ({ key: 'Closed', value: items.filter(i => i.state === 'closed').length }), + weeklyRate: () => ({ key: 'Weekly Close Rate', value: +(recentClosed / 4).toFixed(1), sublabel: 'last 4 weeks' }), + unlabeled: () => ({ key: 'Unlabeled', value: issues.filter(i => i.labels.length === 0).length, color: 'yellow' }), + unassigned: () => ({ + key: 'Unassigned', + value: issues.filter(i => i.assignees.length === 0).length, + color: 'yellow', + }), + medianResolution: () => ({ key: 'Median Resolution', value: formatHours(resPct.median) }), + avgResolution: () => ({ key: 'Avg Resolution', value: formatHours(resPct.avg) }), + p90Resolution: () => ({ key: 'P90 Resolution', value: formatHours(resPct.p90), color: 'red' }), + completed: () => ({ + key: 'Completed', + value: issues.filter(i => i.stateReason === 'completed').length, + color: 'green', + }), + notPlanned: () => ({ + key: 'Not Planned', + value: issues.filter(i => i.stateReason === 'not_planned').length, + color: 'dim', + }), + duplicates: () => ({ key: 'Duplicates', value: issues.filter(i => i.stateReason === 'duplicate').length }), + merged: () => ({ key: 'Merged', value: prs.filter(p => p.merged).length, color: 'purple' }), + closedNoMerge: () => ({ + key: 'Closed (no merge)', + value: prs.filter(p => p.state === 'closed' && !p.merged).length, + color: 'red', + }), + drafts: () => ({ key: 'Drafts', value: prs.filter(p => p.draft).length }), + mergeRate: () => { + const closed = prs.filter(p => p.state === 'closed').length; + const rate = closed > 0 ? (prs.filter(p => p.merged).length / closed) * 100 : 0; + return { key: 'Merge Rate', value: `${rate.toFixed(0)}%`, color: 'accent' }; + }, + medianTTFR: () => ({ key: 'Median TTFR', value: formatHours(ttfrPct.median) }), + avgTTFR: () => ({ key: 'Avg TTFR', value: formatHours(ttfrPct.avg) }), + p90TTFR: () => ({ key: 'P90 TTFR', value: formatHours(ttfrPct.p90), color: 'red' }), + medianTTM: () => ({ key: 'Median TTM', value: formatHours(ttmPct.median) }), + avgTTM: () => ({ key: 'Avg TTM', value: formatHours(ttmPct.avg) }), + p90TTM: () => ({ key: 'P90 TTM', value: formatHours(ttmPct.p90), color: 'red' }), + }; + + return metrics.map(m => lookup[m]?.() ?? { key: m, value: 'N/A' }); +} + +// ── Timeline ──────────────────────────────────────────────────────── + +function computeTimeline(_bucket: 'week', series: string[], items: (Issue | PullRequest)[]): WeekBucket[] { + const weeks = new Map(); + const ensure = (w: string) => { + if (!weeks.has(w)) { + const b: WeekBucket = { week: w }; + for (const s of series) b[s] = 0; + weeks.set(w, b); + } + return weeks.get(w)!; + }; + + for (const item of items) { + const w = ensure(weekKey(item.created)); + if (series.includes('opened')) (w.opened as number)++; + + const closedDate = isIssue(item) ? item.closed : 'merged' in item ? item.merged : null; + if (closedDate) { + const cw = ensure(weekKey(closedDate)); + if (series.includes('closed')) (cw.closed as number)++; + if (series.includes('merged') && !isIssue(item) && item.merged) { + (cw.merged as number)++; + } + } + } + + const sorted = [...weeks.entries()].sort(([a], [b]) => a.localeCompare(b)); + let cumulative = 0; + return sorted.map(([, b]) => { + cumulative += ((b.opened as number) ?? 0) - ((b.closed as number) ?? 0) - ((b.merged as number) ?? 0); + if (series.includes('cumulativeOpen')) b.cumulativeOpen = Math.max(0, cumulative); + return b; + }); +} + +// ── Distribution ──────────────────────────────────────────────────── + +function computeDistribution(field: string, items: (Issue | PullRequest)[]): ChartData { + const counts = new Map(); + const inc = (k: string) => counts.set(k, (counts.get(k) ?? 0) + 1); + + if (field === 'labels') { + let unlabeled = 0; + for (const item of items) { + if (item.labels.length === 0) { + unlabeled++; + continue; + } + for (const l of item.labels) inc(l); + } + if (unlabeled > 0) counts.set('(unlabeled)', unlabeled); + } else if (field === 'age') { + const bucketNames = ['<1d', '1-3d', '3-7d', '1-2w', '2-4w', '1-2m', '>2m']; + const thresholds = [1, 3, 7, 14, 28, 60]; + for (const b of bucketNames) counts.set(b, 0); + for (const item of items.filter(i => i.state === 'open')) { + const d = ageDays(item.created); + const idx = thresholds.findIndex(t => d < t); + inc(bucketNames[idx === -1 ? bucketNames.length - 1 : idx]); + } + } else if (field === 'sizeLabel') { + for (const item of items) { + const sizeLabel = item.labels.find(l => l.startsWith('size/')); + inc(sizeLabel ?? '(no size label)'); + } + } else if (field === 'author') { + for (const item of items) inc(item.author); + const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15); + return { labels: sorted.map(([l]) => l), values: sorted.map(([, v]) => v) }; + } else if (field === 'bucket') { + for (const item of items.filter(i => i.state === 'open' && !isIssue(i))) { + inc((item as PullRequest).bucket); + } + } else if (field === 'linkedIssuePriority') { + for (const item of items.filter(i => !isIssue(i))) { + inc((item as PullRequest).linkedIssuePriority ?? '(none)'); + } + } + + const entries = [...counts.entries()]; + return { labels: entries.map(([l]) => l), values: entries.map(([, v]) => v) }; +} + +// ── Histogram ─────────────────────────────────────────────────────── + +function extractNumericField(field: string, item: Issue | PullRequest): number | null { + if (field === 'resolutionHours' && isIssue(item) && item.closed) { + return (item.closed.getTime() - item.created.getTime()) / MS_PER_HOUR; + } + if (field === 'ttfrHours' && !isIssue(item)) return item.ttfrHours; + if (field === 'ttmHours' && !isIssue(item)) return item.ttmHours; + return null; +} + +function bucketLabel(low: number, high: number | undefined): string { + if (high === undefined) return `>${formatHours(low)}`; + return `${formatHours(low)}-${formatHours(high)}`; +} + +function buildHistogram(values: number[], buckets: number[]): HistogramBucket[] { + const result: HistogramBucket[] = []; + for (let i = 0; i < buckets.length; i++) { + const low = buckets[i]; + const high = buckets[i + 1]; + const label = i === 0 ? `<${formatHours(high ?? low)}` : bucketLabel(low, high); + const count = values.filter(v => (high !== undefined ? v >= low && v < high : v >= low)).length; + result.push({ label, count }); + } + return result; +} + +function autoBuckets(values: number[]): number[] { + if (values.length === 0) return [0]; + const sorted = [...values].sort((a, b) => a - b); + const max = sorted[sorted.length - 1]; + const step = max / 8; + return Array.from({ length: 9 }, (_, i) => +(i * step).toFixed(1)); +} + +function getSizeLabel(item: Issue | PullRequest): string { + return item.labels.find(l => l.startsWith('size/')) ?? '(no size label)'; +} + +function computeHistogram( + field: string, + buckets: number[] | 'auto', + items: (Issue | PullRequest)[], + groupBy?: string +): { histogram?: HistogramBucket[]; histogramGrouped?: Record } { + if (groupBy) { + const groups = new Map(); + for (const item of items) { + const v = extractNumericField(field, item); + if (v === null) continue; + const key = + groupBy === 'sizeLabel' ? getSizeLabel(item) : groupBy === 'labels' ? (item.labels[0] ?? '(unlabeled)') : 'all'; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(v); + } + const result: Record = {}; + for (const [key, vals] of groups) { + const b = buckets === 'auto' ? autoBuckets(vals) : buckets; + result[key] = buildHistogram(vals, b); + } + return { histogramGrouped: result }; + } + + const values = items.map(i => extractNumericField(field, i)).filter((v): v is number => v !== null); + const b = buckets === 'auto' ? autoBuckets(values) : buckets; + return { histogram: buildHistogram(values, b) }; +} + +// ── Table ─────────────────────────────────────────────────────────── + +function computeTable(config: TableSection, items: (Issue | PullRequest)[]): TableRow[] { + let filtered = [...items]; + const { filter } = config; + + if (filter.state) filtered = filtered.filter(i => i.state === filter.state); + if (filter.minAgeDays !== undefined) filtered = filtered.filter(i => ageDays(i.created) >= filter.minAgeDays!); + if (filter.maxComments !== undefined) + filtered = filtered.filter(i => isIssue(i) && i.comments <= filter.maxComments!); + if (filter.labeled === true) filtered = filtered.filter(i => i.labels.length > 0); + if (filter.labeled === false) filtered = filtered.filter(i => i.labels.length === 0); + + if (config.id === 'stale') { + filtered.sort((a, b) => a.created.getTime() - b.created.getTime()); + } else if (config.id === 'engagement') { + filtered.sort((a, b) => { + const ac = isIssue(a) ? a.comments : 0; + const bc = isIssue(b) ? b.comments : 0; + return bc - ac; + }); + } + + return filtered.slice(0, config.limit ?? 20).map(item => { + const row: TableRow = {}; + for (const col of config.columns) { + if (col === 'number') row[col] = item.number; + else if (col === 'title') row[col] = item.title; + else if (col === 'state') row[col] = item.state; + else if (col === 'labels') row[col] = item.labels; + else if (col === 'author') row[col] = item.author; + else if (col === 'age') row[col] = `${Math.floor(ageDays(item.created))}d`; + else if (col === 'comments' && isIssue(item)) row[col] = item.comments; + else if (col === 'reactions' && isIssue(item)) row[col] = item.reactions; + else if (col === 'draft' && !isIssue(item)) row[col] = item.draft; + else if (col === 'priority' && !isIssue(item)) row[col] = item.linkedIssuePriority ?? ''; + else if (col === 'bucket' && !isIssue(item)) row[col] = item.bucket; + else if (col === 'lastActivity' && !isIssue(item)) { + const pr = item; + const latest = [pr.lastCommitDate, pr.lastReviewDate] + .filter((d): d is Date => d !== null) + .sort((a, b) => b.getTime() - a.getTime())[0]; + row[col] = latest ? formatRelativeTime(latest) : ''; + } + } + return row; + }); +} + +// ── Term Frequency ────────────────────────────────────────────────── + +const STOP_WORDS = new Set([ + 'the', + 'and', + 'for', + 'are', + 'but', + 'not', + 'you', + 'all', + 'can', + 'had', + 'her', + 'was', + 'one', + 'our', + 'out', + 'has', + 'have', + 'been', + 'from', + 'this', + 'that', + 'with', + 'they', + 'will', + 'each', + 'make', + 'like', + 'into', + 'them', + 'then', + 'than', + 'its', + 'also', + 'after', + 'should', + 'would', + 'could', + 'when', + 'what', + 'which', + 'their', + 'about', + 'other', + 'there', + 'does', + 'just', + 'more', +]); + +function computeTermFrequency( + _field: string, + filter: { labeled: boolean }, + minCount: number, + items: Issue[] +): { terms: TermCount[]; unusedLabels: string[] } { + const filtered = filter.labeled ? items.filter(i => i.labels.length > 0) : items.filter(i => i.labels.length === 0); + + const wordCounts = new Map(); + for (const item of filtered) { + const words = item.title + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .split(/\s+/); + for (const w of words) { + if (w.length < 3 || STOP_WORDS.has(w)) continue; + wordCounts.set(w, (wordCounts.get(w) ?? 0) + 1); + } + } + + const terms = [...wordCounts.entries()] + .filter(([, c]) => c >= minCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([term, count]) => ({ term, count })); + + const usedLabels = new Set(items.flatMap(i => i.labels)); + const allLabels = new Set(items.flatMap(i => i.labels)); + const unusedLabels = [...allLabels].filter(l => !usedLabels.has(l)); + + return { terms, unusedLabels }; +} + +// ── Page Computation ──────────────────────────────────────────────── + +export function computePage( + pageConfig: PageConfig, + issues: Issue[], + prs: PullRequest[], + ciRuns?: WorkflowRun[] +): PageData { + const items: (Issue | PullRequest)[] = pageConfig.dataSource === 'issues' ? issues : prs; + + const sections: SectionData[] = pageConfig.sections.map((sec): SectionData => { + switch (sec.type) { + case 'stats': { + const result: SectionData = { config: sec, stats: computeStats(sec.metrics, items) }; + if (sec.windows) { + const now = new Date(); + result.windowedStats = {}; + for (const w of sec.windows) { + const cutoff = new Date(now.getTime() - w.days * 24 * 60 * 60 * 1000); + const filtered = items.filter(i => i.created >= cutoff); + result.windowedStats[w.label] = computeStats(sec.metrics, filtered); + } + } + return result; + } + case 'timeline': + return { config: sec, timeline: computeTimeline(sec.bucket, sec.series, items) }; + case 'distribution': + return { config: sec, chart: computeDistribution(sec.field, items) }; + case 'histogram': { + const h = computeHistogram(sec.field, sec.buckets, items, sec.groupBy); + return { config: sec, ...h }; + } + case 'table': + return { config: sec, table: computeTable(sec, items) }; + case 'termFrequency': { + const tf = computeTermFrequency(sec.field, sec.filter, sec.minCount, items as Issue[]); + return { config: sec, terms: tf.terms, unusedLabels: tf.unusedLabels }; + } + case 'ci': + return { config: sec, ci: computeCI(ciRuns ?? []) }; + } + }); + + return { + id: pageConfig.id, + title: pageConfig.title, + generatedAt: new Date().toISOString(), + sections, + }; +} + +function computeCI(runs: WorkflowRun[]): CIData { + // Pass rate helpers + function calcPassRates(subset: WorkflowRun[]): { overall: number; perWf: Record } { + const overall = + subset.length > 0 ? Math.round((subset.filter(r => r.conclusion === 'success').length / subset.length) * 100) : 0; + const perWf: Record = {}; + const byW: Record = {}; + for (const r of subset) (byW[r.workflowName] ??= []).push(r); + for (const [name, wfRuns] of Object.entries(byW)) { + perWf[name] = + wfRuns.length > 0 + ? Math.round((wfRuns.filter(r => r.conclusion === 'success').length / wfRuns.length) * 100) + : 0; + } + return { overall, perWf }; + } + + const allRates = calcPassRates(runs); + const overallPassRate = allRates.overall; + const passRate = allRates.perWf; + + // Weekly timeline + const sorted = [...runs].sort((a, b) => a.created.getTime() - b.created.getTime()); + const start = + sorted.length > 0 ? new Date(sorted[0].created.getTime() - sorted[0].created.getDay() * 86400000) : new Date(); + start.setHours(0, 0, 0, 0); + const end = sorted.length > 0 ? sorted[sorted.length - 1].created : new Date(); + const timeline: CIData['timeline'] = []; + const cur = new Date(start); + while (cur <= end) { + const nxt = new Date(cur.getTime() + 7 * 86400000); + const weekRuns = runs.filter(r => r.created >= cur && r.created < nxt); + timeline.push({ + week: cur.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + pass: weekRuns.filter(r => r.conclusion === 'success').length, + fail: weekRuns.filter(r => r.conclusion === 'failure').length, + }); + cur.setTime(nxt.getTime()); + } + + // Failing jobs — aggregate across all runs + const jobStats: Record = {}; + for (const r of runs) { + for (const j of r.jobs) { + if (j.conclusion === 'skipped') continue; + const s = (jobStats[j.name] ??= { failures: 0, total: 0 }); + s.total++; + if (j.conclusion === 'failure') s.failures++; + } + } + const failingJobs = Object.entries(jobStats) + .filter(([, s]) => s.failures > 0) + .map(([job, s]) => ({ job, failures: s.failures, total: s.total, rate: Math.round((s.failures / s.total) * 100) })) + .sort((a, b) => b.failures - a.failures); + + // Flaky detection — jobs that flip between pass/fail across consecutive runs per workflow + const flaky: CIData['flaky'] = []; + const byWf: Record = {}; + for (const r of runs) (byWf[r.workflowName] ??= []).push(r); + for (const wfRuns of Object.values(byWf)) { + const chronological = [...wfRuns].sort((a, b) => a.created.getTime() - b.created.getTime()); + const jobHistory: Record = {}; + for (const r of chronological) { + for (const j of r.jobs) { + if (j.conclusion === 'skipped') continue; + (jobHistory[j.name] ??= []).push(j.conclusion); + } + } + for (const [job, history] of Object.entries(jobHistory)) { + let flips = 0; + for (let i = 1; i < history.length; i++) { + if (history[i] !== history[i - 1]) flips++; + } + if (flips >= 3) { + const existing = flaky.find(f => f.job === job); + if (existing) existing.flipCount += flips; + else flaky.push({ job, flipCount: flips }); + } + } + } + flaky.sort((a, b) => b.flipCount - a.flipCount); + + // Recent failures + const recentFailures = runs + .filter(r => r.conclusion === 'failure') + .sort((a, b) => b.created.getTime() - a.created.getTime()) + .slice(0, 20) + .map(r => ({ + id: r.id, + workflow: r.workflowName, + date: r.created.toISOString().slice(0, 16).replace('T', ' '), + failedJobs: r.jobs.filter(j => j.conclusion === 'failure').map(j => j.name), + })); + + // Avg duration per job + const jobDurations: Record = {}; + for (const r of runs) { + for (const j of r.jobs) { + if (j.durationMin > 0) (jobDurations[j.name] ??= []).push(j.durationMin); + } + } + const avgDuration: Record = {}; + for (const [job, durations] of Object.entries(jobDurations)) { + avgDuration[job] = Math.round((durations.reduce((a, b) => a + b, 0) / durations.length) * 10) / 10; + } + + return { + overallPassRate, + passRate, + timeline, + failingJobs, + flaky, + recentFailures, + avgDuration, + windows: Object.fromEntries( + [ + ['Past 24h', 1], + ['Past 7 days', 7], + ['Past 30 days', 30], + ].map(([label, days]) => { + const cutoff = new Date(Date.now() - (days as number) * 86400000); + const subset = runs.filter(r => r.created >= cutoff); + const rates = calcPassRates(subset); + return [label, { overallPassRate: rates.overall, passRate: rates.perWf }]; + }) + ), + }; +} diff --git a/.github/dashboard/src/config.ts b/.github/dashboard/src/config.ts new file mode 100644 index 00000000..0f487b85 --- /dev/null +++ b/.github/dashboard/src/config.ts @@ -0,0 +1,141 @@ +import type { DashboardConfig } from './types.js'; + +export const config: DashboardConfig = { + repo: 'aws/agentcore-cli', + outputDir: 'site', + pages: [ + { + id: 'issues', + title: 'Issues', + dataSource: 'issues', + sections: [ + { + type: 'stats', + metrics: [ + 'total', + 'open', + 'closed', + 'weeklyRate', + 'unlabeled', + 'unassigned', + 'medianResolution', + 'avgResolution', + 'p90Resolution', + 'completed', + 'notPlanned', + 'duplicates', + ], + windows: [ + { label: 'Past 24h', days: 1 }, + { label: 'Past 7 days', days: 7 }, + { label: 'Past 30 days', days: 30 }, + ], + }, + { type: 'timeline', bucket: 'week', series: ['opened', 'closed', 'cumulativeOpen'] }, + { type: 'distribution', field: 'labels', chart: 'doughnut' }, + { type: 'distribution', field: 'age', chart: 'bar', orientation: 'horizontal' }, + { + type: 'histogram', + field: 'resolutionHours', + buckets: [0, 1, 4, 8, 12, 24, 48, 72, 168, 336, 720], + groupBy: 'labels', + }, + { + type: 'table', + id: 'engagement', + title: 'Most Discussed Issues', + filter: {}, + columns: ['number', 'title', 'comments', 'reactions', 'state'], + limit: 10, + }, + { + type: 'table', + id: 'stale', + title: 'Stale Open Issues (>14 days, 0 comments)', + filter: { state: 'open', minAgeDays: 14, maxComments: 0 }, + columns: ['number', 'title', 'age', 'labels'], + limit: 20, + }, + { type: 'termFrequency', field: 'title', filter: { labeled: false }, minCount: 3 }, + ], + }, + { + id: 'prs', + title: 'Pull Requests', + dataSource: 'prs', + sections: [ + { + type: 'stats', + metrics: [ + 'total', + 'merged', + 'closedNoMerge', + 'open', + 'drafts', + 'mergeRate', + 'medianTTFR', + 'avgTTFR', + 'p90TTFR', + 'medianTTM', + 'avgTTM', + 'p90TTM', + ], + windows: [ + { label: 'Past 24h', days: 1 }, + { label: 'Past 7 days', days: 7 }, + { label: 'Past 30 days', days: 30 }, + ], + }, + { type: 'timeline', bucket: 'week', series: ['opened', 'merged', 'cumulativeOpen'] }, + { type: 'distribution', field: 'bucket', chart: 'doughnut' }, + { + type: 'histogram', + field: 'ttfrHours', + title: 'Time to First Review', + buckets: [0, 0.25, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 168], + }, + { + type: 'histogram', + field: 'ttmHours', + title: 'Time to Merge', + buckets: [0, 0.25, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 168], + }, + { type: 'distribution', field: 'sizeLabel', chart: 'doughnut' }, + { + type: 'histogram', + field: 'ttmHours', + title: 'Time to Merge by Size', + buckets: [0, 0.25, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 168], + groupBy: 'sizeLabel', + }, + { + type: 'distribution', + field: 'author', + chart: 'bar', + orientation: 'horizontal', + }, + { + type: 'table', + id: 'stale', + title: 'Stale Open PRs (>7 days)', + filter: { state: 'open', minAgeDays: 7 }, + columns: ['number', 'title', 'age', 'author', 'priority', 'lastActivity', 'draft'], + limit: 15, + }, + ], + }, + { + id: 'ci', + title: 'CI / Tests', + dataSource: 'ci', + sections: [ + { + type: 'ci', + workflows: ['Build and Test', 'E2E Tests (Full Suite)', 'E2E Tests'], + branch: 'main', + maxRuns: 900, + }, + ], + }, + ], +}; diff --git a/.github/dashboard/src/generate.ts b/.github/dashboard/src/generate.ts new file mode 100644 index 00000000..e79f78e8 --- /dev/null +++ b/.github/dashboard/src/generate.ts @@ -0,0 +1,45 @@ +import { computePage, parseIssues, parsePRs } from './compute.js'; +import { config } from './config.js'; +import { fetchCIRuns, fetchIssues, fetchPRs } from './github.js'; +import { renderPage } from './render.js'; +import { mkdirSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outDir = join(__dirname, '..', config.outputDir); + +function main(): void { + mkdirSync(outDir, { recursive: true }); + + const rawIssues = fetchIssues(config.repo); + const issues = parseIssues(rawIssues); + + const rawPRs = fetchPRs(config.repo); + const prs = parsePRs(rawPRs); + + // Fetch CI runs if any page needs them + const ciPage = config.pages.find(p => p.dataSource === 'ci'); + const ciSection = ciPage?.sections.find(s => s.type === 'ci'); + const ciRuns = ciSection + ? fetchCIRuns(config.repo, ciSection.workflows, ciSection.branch, ciSection.maxRuns) + : undefined; + + for (const page of config.pages) { + const data = computePage(page, issues, prs, ciRuns); + const html = renderPage(data, config); + const outPath = join(outDir, `${page.id}.html`); + writeFileSync(outPath, html); + console.error(` → ${outPath}`); + } + + // Index redirect to first page + writeFileSync( + join(outDir, 'index.html'), + `` + ); + console.error(` → ${join(outDir, 'index.html')}`); + console.error('Done!'); +} + +main(); diff --git a/.github/dashboard/src/github.ts b/.github/dashboard/src/github.ts new file mode 100644 index 00000000..76ab0712 --- /dev/null +++ b/.github/dashboard/src/github.ts @@ -0,0 +1,131 @@ +import type { GHIssue, GHPullRequestNode, WorkflowJob, WorkflowRun } from './types.js'; +import { execSync } from 'node:child_process'; + +const EXEC_OPTS = { encoding: 'utf-8' as const, maxBuffer: 50 * 1024 * 1024 }; + +export function fetchIssues(repo: string): GHIssue[] { + process.stderr.write(`Fetching issues for ${repo}...\n`); + const raw = execSync(`gh api --paginate '/repos/${repo}/issues?state=all&per_page=100'`, EXEC_OPTS); + const items = JSON.parse(raw.trim()) as GHIssue[]; + const issues = items.filter(i => !i.pull_request); + process.stderr.write(` Fetched ${issues.length} issues\n`); + return issues; +} + +export function fetchPRs(repo: string): GHPullRequestNode[] { + const [owner, name] = repo.split('/'); + const prs: GHPullRequestNode[] = []; + let cursor: string | null = null; + let page = 0; + + const query = ` + query($cursor: String) { + repository(owner: "${owner}", name: "${name}") { + pullRequests(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { + number title state createdAt mergedAt closedAt isDraft + author { login } + labels(first: 10) { nodes { name } } + reviews(first: 20) { nodes { author { login } state submittedAt } } + commits(last: 1) { nodes { commit { committedDate } } } + closingIssuesReferences(first: 3) { nodes { number labels(first: 5) { nodes { name } } } } + } + } + } + }`; + + for (;;) { + page++; + process.stderr.write(`Fetching PRs page ${page}...\n`); + const cursorArg = cursor ? `-f cursor="${cursor}"` : ''; + const raw = execSync(`gh api graphql -f query='${query}' ${cursorArg}`, EXEC_OPTS); + const resp = JSON.parse(raw) as { + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: boolean; endCursor: string }; + nodes: GHPullRequestNode[]; + }; + }; + }; + }; + const data = resp.data.repository.pullRequests; + prs.push(...data.nodes); + if (!data.pageInfo.hasNextPage) break; + cursor = data.pageInfo.endCursor; + } + + const filtered = prs.filter(pr => pr.author?.login !== 'github-actions[bot]'); + process.stderr.write(` Fetched ${filtered.length} PRs (filtered from ${prs.length})\n`); + return filtered; +} + +interface GHWorkflow { + id: number; + name: string; +} +interface GHRunsResponse { + workflow_runs: { id: number; conclusion: string; created_at: string }[]; +} +interface GHJobsResponse { + jobs: { name: string; conclusion: string; started_at: string; completed_at: string }[]; +} + +export function fetchCIRuns(repo: string, workflowNames: string[], branch: string, maxRuns: number): WorkflowRun[] { + process.stderr.write(`Fetching CI runs for ${branch}...\n`); + const wfList = JSON.parse( + execSync(`gh api '/repos/${repo}/actions/workflows' --jq '.workflows'`, EXEC_OPTS) + ) as GHWorkflow[]; + const matched = wfList.filter(w => workflowNames.includes(w.name)); + const runs: WorkflowRun[] = []; + const perWf = Math.ceil(maxRuns / matched.length); + + for (const wf of matched) { + process.stderr.write(` ${wf.name}...\n`); + let fetched = 0; + let page = 1; + while (fetched < perWf) { + const resp = JSON.parse( + execSync( + `gh api '/repos/${repo}/actions/workflows/${wf.id}/runs?branch=${branch}&per_page=100&page=${page}'`, + EXEC_OPTS + ) + ) as GHRunsResponse; + if (resp.workflow_runs.length === 0) break; + for (const run of resp.workflow_runs) { + if (fetched >= perWf) break; + // Only fetch job details for failed runs — success runs don't need them + let jobs: WorkflowJob[] = []; + if (run.conclusion === 'failure') { + const jobsResp = JSON.parse( + execSync(`gh api '/repos/${repo}/actions/runs/${run.id}/jobs'`, EXEC_OPTS) + ) as GHJobsResponse; + jobs = jobsResp.jobs.map( + (j): WorkflowJob => ({ + name: j.name, + conclusion: j.conclusion ?? 'in_progress', + durationMin: + j.completed_at && j.started_at + ? Math.round(((new Date(j.completed_at).getTime() - new Date(j.started_at).getTime()) / 60000) * 10) / + 10 + : 0, + }) + ); + } + runs.push({ + id: run.id, + workflowName: wf.name, + conclusion: run.conclusion ?? 'in_progress', + created: new Date(run.created_at), + jobs, + }); + fetched++; + } + process.stderr.write(` ...${fetched} runs\n`); + page++; + } + } + process.stderr.write(` ${runs.length} CI runs fetched\n`); + return runs; +} diff --git a/.github/dashboard/src/render.ts b/.github/dashboard/src/render.ts new file mode 100644 index 00000000..cb905141 --- /dev/null +++ b/.github/dashboard/src/render.ts @@ -0,0 +1,356 @@ +import type { DashboardConfig, PageData, SectionData } from './types.js'; + +export function renderPage(page: PageData, config: DashboardConfig): string { + const nav = config.pages + .map(p => `${p.title}`) + .join(''); + + const sections = page.sections.map((s, i) => renderSection(s, i)).join('\n'); + + return ( + ` + + +${page.title} — ${config.repo} Dashboard + -
-
+
+

📈 Pass/Fail Over Time

-
-
+
+

❌ Most Failing Jobs

{ci.failingJobs.length > 0 ? ( @@ -145,12 +144,12 @@ export default function CISection({ ci }: { ci: CIData }) { {ci.failingJobs.map(j => { const cls = j.rate >= 20 ? 'b-red' : j.rate >= 10 ? 'b-yellow' : 'b-dim'; return ( - + ); @@ -161,7 +160,7 @@ export default function CISection({ ci }: { ci: CIData }) {

No failures!

)} -
+

🔄 Flaky Jobs (pass↔fail flips)

{ci.flaky.length > 0 ? (
{j.job} {j.failures} {j.total} - {j.rate}% + {j.rate}%
@@ -173,10 +172,10 @@ export default function CISection({ ci }: { ci: CIData }) { {ci.flaky.map(f => ( - + ))} @@ -188,12 +187,12 @@ export default function CISection({ ci }: { ci: CIData }) { -
-
+
+

🕐 Avg Job Duration

-
+

🔥 Recent Failures

{ci.recentFailures.length > 0 ? (
{f.job} - {f.flipCount} + {f.flipCount}
@@ -206,12 +205,12 @@ export default function CISection({ ci }: { ci: CIData }) { {ci.recentFailures.map(r => ( - +
{r.date} {r.workflow} {r.failedJobs.map(j => ( - + {j} ))} diff --git a/.github/dashboard/src/components/ChartSection.tsx b/.github/dashboard/src/components/ChartSection.tsx index 12db7c08..2eb6bcc0 100644 --- a/.github/dashboard/src/components/ChartSection.tsx +++ b/.github/dashboard/src/components/ChartSection.tsx @@ -1,6 +1,5 @@ import { CHART_COLORS, PALETTE } from '../palette.js'; import type { DistributionSection, SectionData, TimelineSection } from '../types.js'; -import React from 'react'; function buildTimelineConfig(s: SectionData) { const cfg = s.config as TimelineSection; diff --git a/.github/dashboard/src/components/Page.tsx b/.github/dashboard/src/components/Page.tsx index cee8027e..15c100e7 100644 --- a/.github/dashboard/src/components/Page.tsx +++ b/.github/dashboard/src/components/Page.tsx @@ -1,7 +1,6 @@ import { PALETTE } from '../palette.js'; import type { DashboardConfig, PageData, SectionData } from '../types.js'; import Section from './Section.js'; -import React from 'react'; const CSS = ` :root{--bg:${PALETTE.bg};--card:${PALETTE.card};--text:${PALETTE.text};--border:${PALETTE.border};--dim:${PALETTE.dim};--accent:${PALETTE.accent};--green:${PALETTE.green};--red:${PALETTE.red};--yellow:${PALETTE.yellow};--purple:${PALETTE.purple}} @@ -86,23 +85,23 @@ function WindowedSections({ page, repo }: { page: PageData; repo: string }) { return ( <> -
+
{tabs.map((t, j) => ( - ))}
{tabs.map((t, j) => ( -
0 ? { display: 'none' } : undefined}> -
+
0 ? { display: 'none' } : undefined}> +
{allSections[t].map((s, i) => ( -
+
))}
))} - ); } @@ -112,21 +111,21 @@ export default function Page({ page, config }: { page: PageData; config: Dashboa return ( <> - + {`${page.title} — ${config.repo} Dashboard`} ); diff --git a/.github/dashboard/src/components/Section.tsx b/.github/dashboard/src/components/Section.tsx index 828fed83..ef4b7403 100644 --- a/.github/dashboard/src/components/Section.tsx +++ b/.github/dashboard/src/components/Section.tsx @@ -5,7 +5,6 @@ import ChartSection from './ChartSection.js'; import StatsSection from './StatsSection.js'; import TableSectionComponent from './TableSection.js'; import TermFrequencySection from './TermFrequencySection.js'; -import React from 'react'; function sectionTitle(s: SectionData): string { const c = s.config; @@ -87,24 +86,24 @@ function TrendChart({ function WeeklyTable({ data }: { data: { weeks: string[]; rows: Record } }) { return ( -
+
{data.weeks.map(w => ( - + ))} {Object.entries(data.rows).map(([metric, values]) => ( - + - {values.map((v, i) => ( - + {values.map(v => ( + ))} ))} @@ -152,10 +151,10 @@ export default function Section({ const wide = WIDE_TYPES.has(sectionData.config.type); const title = sectionTitle(sectionData); return ( -
+

{title} - +

diff --git a/.github/dashboard/src/components/StatsSection.tsx b/.github/dashboard/src/components/StatsSection.tsx index a867eea1..206b0ab4 100644 --- a/.github/dashboard/src/components/StatsSection.tsx +++ b/.github/dashboard/src/components/StatsSection.tsx @@ -1,6 +1,5 @@ import { PALETTE } from '../palette.js'; import type { StatValue } from '../types.js'; -import React from 'react'; const COLOR_MAP: Record = { green: PALETTE.green, @@ -13,13 +12,13 @@ const COLOR_MAP: Record = { function StatRow({ items, small }: { items: StatValue[]; small?: boolean }) { return ( -
+
{items.map(st => ( -
-
+
+
{st.value}
-
+
{st.key} {st.sublabel ? ` (${st.sublabel})` : ''}
diff --git a/.github/dashboard/src/components/TableSection.tsx b/.github/dashboard/src/components/TableSection.tsx index 4649004a..814dbd88 100644 --- a/.github/dashboard/src/components/TableSection.tsx +++ b/.github/dashboard/src/components/TableSection.tsx @@ -1,5 +1,4 @@ import type { TableRow, TableSection as TableSectionConfig } from '../types.js'; -import React from 'react'; const PRIORITY_COLORS: Record = { P0: 'b-red', @@ -24,11 +23,11 @@ function CellValue({ col, value, repo, isPR }: { col: string; value: unknown; re return #{String(value)}; } if (col === 'labels' && Array.isArray(value)) { - if (value.length === 0) return unlabeled; + if (value.length === 0) return unlabeled; return ( <> {value.map((l: string) => ( - + {l} ))} @@ -37,19 +36,19 @@ function CellValue({ col, value, repo, isPR }: { col: string; value: unknown; re } if (col === 'state') { const cls = value === 'open' ? 'b-green' : 'b-dim'; - return {str}; + return {str}; } if (col === 'draft') { - return value ? draft : null; + return value ? draft : null; } if (col === 'priority') { - if (!value) return ; + if (!value) return ; const cls = PRIORITY_COLORS[str] ?? 'b-dim'; - return {str}; + return {str}; } if (col === 'bucket') { const cls = BUCKET_COLORS[str] ?? 'b-dim'; - return {str}; + return {str}; } if (col === 'age') return <>{String(value)}d; return <>{str}; @@ -66,20 +65,20 @@ export default function TableSection({ }) { const isPR = config.columns.includes('draft'); return ( -
+
Metric{w}{w}
{metric} {v}{v}
{config.columns.map(c => ( - + ))} - {table.map((row, i) => ( - + {table.map(row => ( + {config.columns.map(col => ( - ))} diff --git a/.github/dashboard/src/components/TermFrequencySection.tsx b/.github/dashboard/src/components/TermFrequencySection.tsx index c03a946f..84e67cb4 100644 --- a/.github/dashboard/src/components/TermFrequencySection.tsx +++ b/.github/dashboard/src/components/TermFrequencySection.tsx @@ -1,11 +1,10 @@ import type { TermCount } from '../types.js'; -import React from 'react'; export default function TermFrequencySection({ terms, unusedLabels }: { terms: TermCount[]; unusedLabels: string[] }) { return ( <> {terms.length > 0 && ( -
+
{c}{c}
+
@@ -15,9 +14,9 @@ export default function TermFrequencySection({ terms, unusedLabels }: { terms: T {terms.map(t => ( - + @@ -27,11 +26,11 @@ export default function TermFrequencySection({ terms, unusedLabels }: { terms: T )} {unusedLabels.length > 0 && ( -
+

Defined but Unused Labels

{unusedLabels.map(l => ( - + {l} ))} diff --git a/.github/dashboard/src/generate.tsx b/.github/dashboard/src/generate.tsx index f212745c..6ef98e1a 100644 --- a/.github/dashboard/src/generate.tsx +++ b/.github/dashboard/src/generate.tsx @@ -6,8 +6,6 @@ import { transformSync } from 'esbuild'; import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; const __dirname = dirname(fileURLToPath(import.meta.url)); const outDir = join(__dirname, '..', config.outputDir); @@ -39,7 +37,7 @@ function main(): void { for (const page of config.pages) { const data = computePage(page, issues, prs, ciRuns); - const markup = renderToStaticMarkup(); + const markup = String(); const html = `${markup}`; const outPath = join(outDir, `${page.id}.html`); writeFileSync(outPath, html); diff --git a/.github/dashboard/tsconfig.json b/.github/dashboard/tsconfig.json index 16d5dbaa..2272f203 100644 --- a/.github/dashboard/tsconfig.json +++ b/.github/dashboard/tsconfig.json @@ -8,7 +8,8 @@ "skipLibCheck": true, "outDir": "dist", "rootDir": "src", - "jsx": "react-jsx" + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html" }, "include": ["src"] }
- {t.term} + {t.term} {t.count}