Initial curated RepoPulse source from 2524ccc3c1c27739996dccb2b7992989f88cd26d
No files matched your search
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.gitea
|
||||
.env
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
data
|
||||
*.db
|
||||
*.sqlite*
|
||||
@@ -0,0 +1,40 @@
|
||||
# RepoPulse UI port. The registered Unraid deployment publishes 1254 -> 8000.
|
||||
REPOPULSE_PORT=1254
|
||||
|
||||
# Directory containing repositories. From a repository checkout, '..' scans sibling repositories.
|
||||
# On the Unraid deploy runner this resolves to /mnt/cache/appdata/gitea-deploy-runners/sources.
|
||||
REPOPULSE_SOURCE_ROOT=..
|
||||
|
||||
# Local Compose uses a checkout-relative bind mount. The production Compose
|
||||
# deployment defaults to the named `repopulse_data` volume when this is unset,
|
||||
# keeping SQLite state outside the exact-source tree that deployments replace.
|
||||
REPOPULSE_DATA_ROOT=./data
|
||||
|
||||
# Keep only one repository scan active per application process by default.
|
||||
REPOPULSE_MAX_CONCURRENT_SCANS=1
|
||||
REPOPULSE_JOB_LEASE_SECONDS=900
|
||||
|
||||
# Transparent bounded analysis limits. Values are clamped by the backend.
|
||||
REPOPULSE_SCAN_MAX_FILES=10000
|
||||
REPOPULSE_SCAN_MAX_TEXT_BYTES=2000000
|
||||
REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES=50000000
|
||||
REPOPULSE_SCAN_MAX_FINDINGS=2000
|
||||
REPOPULSE_SCAN_MAX_SECRET_FINDINGS=20
|
||||
|
||||
# Retain compact history while limiting detailed report payload retention.
|
||||
REPOPULSE_HISTORY_PER_REPOSITORY=200
|
||||
REPOPULSE_FULL_REPORTS_PER_REPOSITORY=30
|
||||
REPOPULSE_REPORT_RETENTION_DAYS=365
|
||||
|
||||
# Optional server-side Gitea connector. Never put this token in frontend code.
|
||||
REPOPULSE_GITEA_URL=
|
||||
REPOPULSE_GITEA_TOKEN=
|
||||
REPOPULSE_GITEA_BRANCH_WORKERS=4
|
||||
REPOPULSE_GITEA_WEBHOOK_SECRET=
|
||||
REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS=300
|
||||
REPOPULSE_REPOSITORY_CACHE=/data/repos
|
||||
REPOPULSE_MAX_QUEUED_SCANS=100
|
||||
|
||||
# Optional mutation boundary for deployments that are not already protected by
|
||||
# an authenticated reverse proxy. Send the same value as X-RepoPulse-Token.
|
||||
REPOPULSE_OPERATOR_TOKEN=
|
||||
@@ -0,0 +1,28 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Local scan state
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/*.bak
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,9 @@
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[allowlists]]
|
||||
description = "Intentional credential detector fixture; not an operational credential"
|
||||
condition = "AND"
|
||||
paths = ['backend/tests/test_signals_v11\.py$']
|
||||
regexTarget = "line"
|
||||
regexes = ['abcdefghijk1234']
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM node:22-alpine AS frontend-build
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
REPOPULSE_DB=/data/repopulse.db \
|
||||
REPOPULSE_SCAN_ROOT=/scan \
|
||||
REPOPULSE_STATIC_DIR=/app/static
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/pyproject.toml ./pyproject.toml
|
||||
COPY backend/app ./app
|
||||
RUN pip install --no-cache-dir .
|
||||
COPY --from=frontend-build /frontend/dist ./static
|
||||
|
||||
VOLUME ["/data", "/scan"]
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,3 @@
|
||||
# Public source export
|
||||
|
||||
This source snapshot was generated from the private canonical repository at revision `2524ccc3c1c27739996dccb2b7992989f88cd26d`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||
@@ -0,0 +1,446 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"sourceRevision": "2524ccc3c1c27739996dccb2b7992989f88cd26d",
|
||||
"files": [
|
||||
{
|
||||
"path": ".dockerignore",
|
||||
"sha256": "fcd8146da6fd8900a8513541c5925d3a3efc71a7a0670e5f7fd0ccf947899bd4",
|
||||
"bytes": 130
|
||||
},
|
||||
{
|
||||
"path": ".env.example",
|
||||
"sha256": "c42f86ef0802bff819144099a69f89c678905bd4af4c9da9b9e801b3bc06943f",
|
||||
"bytes": 1694
|
||||
},
|
||||
{
|
||||
"path": ".gitignore",
|
||||
"sha256": "0e9ad55f2380d7bb57d8c72c99d79362a837eea9efb2c8c9530e7118cd94d9cb",
|
||||
"bytes": 292
|
||||
},
|
||||
{
|
||||
"path": ".gitleaks.toml",
|
||||
"sha256": "14d325c51a68f2153ea45af0ef2b6aff7cd503c5540f4197d774d7d3db268495",
|
||||
"bytes": 256
|
||||
},
|
||||
{
|
||||
"path": "Dockerfile.unraid",
|
||||
"sha256": "3d4e6da865479357527d88e2d766890d86a3c1877de7a004a037e5ef2cd139b0",
|
||||
"bytes": 788
|
||||
},
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "daf72438ac9243a092c38f5d3b8d7131245142d4a800dae91ac84fe874ab4814",
|
||||
"bytes": 5271
|
||||
},
|
||||
{
|
||||
"path": "backend/Dockerfile",
|
||||
"sha256": "b5ebf1f12895c50890cb533e09a7e213c2331717027821a2545f4fd0330403c7",
|
||||
"bytes": 505
|
||||
},
|
||||
{
|
||||
"path": "backend/app/__init__.py",
|
||||
"sha256": "de10a39a27b9866c1f2ee16fadc50be1002d3edb151fdf74a1c7eb9e0f129a08",
|
||||
"bytes": 34
|
||||
},
|
||||
{
|
||||
"path": "backend/app/acquisition.py",
|
||||
"sha256": "7c2cc8cb7b8ce7e14d47398b18135f30d486bd0bcfe28b0da8707cc5058b0f3b",
|
||||
"bytes": 11052
|
||||
},
|
||||
{
|
||||
"path": "backend/app/architecture.py",
|
||||
"sha256": "37fc9680b7ae27638c88552696e9692dcdff180ab8b4b0dfe322acad60179161",
|
||||
"bytes": 13252
|
||||
},
|
||||
{
|
||||
"path": "backend/app/git_analysis.py",
|
||||
"sha256": "e5fc4778c7288884837d3cb89ca74046308b70432f72742174b5bb2afdcb19ce",
|
||||
"bytes": 3864
|
||||
},
|
||||
{
|
||||
"path": "backend/app/gitea.py",
|
||||
"sha256": "af5bc71c699c428b0c79a0c57ddda919838dbd37e2aa1c931b0e0c9ea4ce0061",
|
||||
"bytes": 14276
|
||||
},
|
||||
{
|
||||
"path": "backend/app/jobs.py",
|
||||
"sha256": "0d845ff11cd5fc32fa7350eafeb4757a17d403e70bcc1312a295dc2793263dca",
|
||||
"bytes": 12780
|
||||
},
|
||||
{
|
||||
"path": "backend/app/main.py",
|
||||
"sha256": "897a75de65118c978e2d861f535be2ac269ccb2bd81b658af54a44eb5736c352",
|
||||
"bytes": 19644
|
||||
},
|
||||
{
|
||||
"path": "backend/app/models.py",
|
||||
"sha256": "6ac96bb4cf25d9e5851bc811569d5e896587c502b58b798aaf494b8fc3a46edd",
|
||||
"bytes": 11240
|
||||
},
|
||||
{
|
||||
"path": "backend/app/policy.py",
|
||||
"sha256": "99d53303ae7bff2156bd748059885ce2621b651a96bb10b8b7f1783d01c88469",
|
||||
"bytes": 3282
|
||||
},
|
||||
{
|
||||
"path": "backend/app/release_gate.py",
|
||||
"sha256": "1f17f8863956b6a52f880c20314bd8a5e2df2685494c4675b732873ef35cfca8",
|
||||
"bytes": 5772
|
||||
},
|
||||
{
|
||||
"path": "backend/app/reporting.py",
|
||||
"sha256": "8e9e0e77df8c3950559d353021a6ac0622e4da2dd2c5a9e03c2ab8c80ef6938f",
|
||||
"bytes": 5359
|
||||
},
|
||||
{
|
||||
"path": "backend/app/rules.py",
|
||||
"sha256": "031c62ba9875dfa2a371dfc8dae9b40890eae446a725b587854128e6fbe49242",
|
||||
"bytes": 3484
|
||||
},
|
||||
{
|
||||
"path": "backend/app/scanner.py",
|
||||
"sha256": "9dfc2de46b49168d73bd9afeae30c104addd783eb1f310fc5dfcedc4434430e0",
|
||||
"bytes": 26442
|
||||
},
|
||||
{
|
||||
"path": "backend/app/signals.py",
|
||||
"sha256": "c83e017a5a7c750c9ece3d53db2671a895c5d563fde5a9d2451381e633e822a6",
|
||||
"bytes": 20351
|
||||
},
|
||||
{
|
||||
"path": "backend/app/store.py",
|
||||
"sha256": "01782cd4f171446b8e53a993cf8ef4b697941f4681d29fd2547de7f9410b5fac",
|
||||
"bytes": 70318
|
||||
},
|
||||
{
|
||||
"path": "backend/pyproject.toml",
|
||||
"sha256": "2cf2e3f4fbf41cbc679de9ffe395109a40a8703a19e1095993d90385fb04d84f",
|
||||
"bytes": 469
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_api.py",
|
||||
"sha256": "c2b7a9c3c6b65e957a7aa55d4fa1aa5bca1ee24b9a7cea888346ad51523ef57f",
|
||||
"bytes": 4895
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_api_v11.py",
|
||||
"sha256": "c9d26544553a9282321d751c53c138b1f076e63ad1aefa37f3b5729a36e9c17c",
|
||||
"bytes": 4940
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_architecture.py",
|
||||
"sha256": "7ef1f1fbb92ead95e9e4be69a22b7d84a276bb5177a7f5929644bc290fa69437",
|
||||
"bytes": 3749
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_gitea.py",
|
||||
"sha256": "94e08c1ce45dc0a0c6890c51ede8e900f60a176b760fd3d27866265642b45629",
|
||||
"bytes": 8220
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_jobs.py",
|
||||
"sha256": "7e82af630918c523a1d9d6d0e64b924ff4bed335ce3a565ee76bb9150a8ba295",
|
||||
"bytes": 9632
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_maintenance.py",
|
||||
"sha256": "a83497cff1c9c4f06ec5da1e396bb2816974f36bfa0cbc7c77d63b8d7411c143",
|
||||
"bytes": 7494
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_migrations.py",
|
||||
"sha256": "e9bdf365e1c580b68e2f3f0426aed189869f7c8620b2eb17fdd36da96df0d76c",
|
||||
"bytes": 2872
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_reporting.py",
|
||||
"sha256": "6b91210461eb560a28515008fe35575f09fc12377fbf51bd430b6c760250ede3",
|
||||
"bytes": 475
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_scanner.py",
|
||||
"sha256": "8373156460d71990219cf0f73fa47ba7645c7643b874be0ab96ff09fc7b37630",
|
||||
"bytes": 9852
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_signals.py",
|
||||
"sha256": "c990c4002ee89c7fb0484fc2c54af75a0f8158b4f585dde8d4d0eb3ea1bb1842",
|
||||
"bytes": 4351
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_signals_v11.py",
|
||||
"sha256": "eaf0c0d852bbfa9cf58fa895c34fe7a30e6d44527bcb69c70e46432a45a31add",
|
||||
"bytes": 3051
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_store.py",
|
||||
"sha256": "c2594f5b86e0094434c16be28bf656c79320c0d216790cbc302b2057102ff09e",
|
||||
"bytes": 6898
|
||||
},
|
||||
{
|
||||
"path": "compose.yaml",
|
||||
"sha256": "a168455adaf981152221e41e9efd57439c67df9838570ce22ae10afb08bada74",
|
||||
"bytes": 3000
|
||||
},
|
||||
{
|
||||
"path": "docker-compose.yml",
|
||||
"sha256": "55077c9147f0d48a601b688bfd1a2690f3d252deae49811fb53a439cad814bb1",
|
||||
"bytes": 2495
|
||||
},
|
||||
{
|
||||
"path": "docs/ARCHITECTURE.md",
|
||||
"sha256": "0f766e94aaeb1bc3b396599b549db2a82ffe882036c3a6f0fddab7ef36577f09",
|
||||
"bytes": 13428
|
||||
},
|
||||
{
|
||||
"path": "docs/screenshots/demo-explorer.png",
|
||||
"sha256": "74d102b7ab96973a38e6c190f36fae7ffa546c14482f82c1e912ea2e03eac0e8",
|
||||
"bytes": 183006
|
||||
},
|
||||
{
|
||||
"path": "docs/screenshots/demo-portfolio.png",
|
||||
"sha256": "95f22065e74ee3698e7f0ec9d8a6dbf0e4e37f7a9662353ca864febc459f5c6c",
|
||||
"bytes": 785643
|
||||
},
|
||||
{
|
||||
"path": "frontend/Dockerfile",
|
||||
"sha256": "c22443a8c260bab6f73dfbb0db0fe84a63d0b05638bae07c0597b800409f297d",
|
||||
"bytes": 320
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/mock-api.mjs",
|
||||
"sha256": "d7d6edaa29df35e6bcb0607ee0371f7679dcfbce04f9d9d1c484a79a590f65cf",
|
||||
"bytes": 8871
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/repopulse.spec.ts",
|
||||
"sha256": "5676b76de5a4ac95ff7b619e43fa9fcc43ba0ab62946958ecead68570db3b28a",
|
||||
"bytes": 2815
|
||||
},
|
||||
{
|
||||
"path": "frontend/e2e/start-vite.mjs",
|
||||
"sha256": "0189002d9a1da6ad5006f38d503588e419b2f4b0089ac59210a0da6532fd57b2",
|
||||
"bytes": 462
|
||||
},
|
||||
{
|
||||
"path": "frontend/index.html",
|
||||
"sha256": "19bf66c0e00656b483b90ba07f616227aa9755a1109424b77c57dc5ff682493b",
|
||||
"bytes": 668
|
||||
},
|
||||
{
|
||||
"path": "frontend/nginx.conf",
|
||||
"sha256": "c07a3ce7936beef0d3f124f18878b46c87693150e83cf571cebbf3f6f6635885",
|
||||
"bytes": 520
|
||||
},
|
||||
{
|
||||
"path": "frontend/package-lock.json",
|
||||
"sha256": "43f9a45011d41977cca8eda3987ccbe2409f687db2e690105b01e90d9b088d9b",
|
||||
"bytes": 116420
|
||||
},
|
||||
{
|
||||
"path": "frontend/package.json",
|
||||
"sha256": "c84753f99331918ce8f97115c7e6a800260ea15d774412fe468985a45cdde0c8",
|
||||
"bytes": 754
|
||||
},
|
||||
{
|
||||
"path": "frontend/playwright.config.ts",
|
||||
"sha256": "f56a666f9b23c63e87add11d4c1dea994597a56c5e5a8f4d0a8d7dd0ced1934a",
|
||||
"bytes": 1313
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/brand/repopulse-logo-light.svg",
|
||||
"sha256": "86e97e67ec6371bf18400b0380d1a93880581ee06e3bc4cb8417caced1a4d414",
|
||||
"bytes": 825
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/brand/repopulse-logo.svg",
|
||||
"sha256": "a882e6d762a4019527b1e9cb8110afdd86549ca46e430507ca618cf67446765a",
|
||||
"bytes": 825
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/brand/repopulse-mark-mono.svg",
|
||||
"sha256": "140954a6d337e590adb0c8a8d455d34219f4776a26cb74b103fa43f451a03195",
|
||||
"bytes": 426
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/brand/repopulse-mark.svg",
|
||||
"sha256": "7492784108d6de42363ea2b767647d062eb45fd98020b8749e7123f5eb7ad293",
|
||||
"bytes": 465
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/favicon.svg",
|
||||
"sha256": "7492784108d6de42363ea2b767647d062eb45fd98020b8749e7123f5eb7ad293",
|
||||
"bytes": 465
|
||||
},
|
||||
{
|
||||
"path": "frontend/public/site.webmanifest",
|
||||
"sha256": "5ced74fc20879c6f096730534aaf9331abe7b16a62c6274de548f976b2153977",
|
||||
"bytes": 374
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/App.test.tsx",
|
||||
"sha256": "108c3e2190aba3b7125e2c1911eb5b2fa708f95f989d2d25e6e32ee15c77199f",
|
||||
"bytes": 5893
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/App.tsx",
|
||||
"sha256": "83d9b1b0fe9d35b5ea75370105b80343aca7aecbe6f7dbd16cd103160e69c015",
|
||||
"bytes": 46615
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/FindingsView.test.tsx",
|
||||
"sha256": "86fdea7d89d3b0fa0e2f8edd93c98414d432b56fc7bc1b2a55e70bacea477ea6",
|
||||
"bytes": 3996
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/OverviewView.test.tsx",
|
||||
"sha256": "048e07c6691b6b8acd7623389d38298cc8c8cfba3169e188baf529c1a4b0eee4",
|
||||
"bytes": 1612
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/api.test.ts",
|
||||
"sha256": "361784096733107ed43281d0401baa208285de1edfd19ca62caaf3e85f3b474c",
|
||||
"bytes": 2578
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/api.ts",
|
||||
"sha256": "c74a07618e9fc27c60cad6707e481f87731423eb2265082cb9921f58bdff397e",
|
||||
"bytes": 10726
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/catalog.test.ts",
|
||||
"sha256": "7a1d9689a57a2cf9208f03770a86bf8c8ffc3bd0dfbbe4f42fd3c3d714d2a03e",
|
||||
"bytes": 3201
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/catalog.ts",
|
||||
"sha256": "97b3aa927261317aaceb2fc3072d42380c7968ca681011f7c967039d189ffce0",
|
||||
"bytes": 5508
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/ArchitectureView.tsx",
|
||||
"sha256": "c57e08a6ab7d8a60627ec57425b2f5c882d78ff9653a285b873e26016b4d528f",
|
||||
"bytes": 4707
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/Brand.tsx",
|
||||
"sha256": "2d97a7fec8d1ba55a19113a9d592a6c6b00378ab66c8f8028d259c8105031983",
|
||||
"bytes": 1593
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/DashboardView.test.tsx",
|
||||
"sha256": "32654d5c4db5475f451fd0fa90032d68b25d29d94909550aee9b3b19f8169b0b",
|
||||
"bytes": 2404
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/DashboardView.tsx",
|
||||
"sha256": "61548df575c977ac94deb81516c2e4281f9bb6eb52899689e7e5f8fdc781b9b7",
|
||||
"bytes": 11005
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/DependenciesView.tsx",
|
||||
"sha256": "cb0d4d855ba32794770c048e48c1788836dbbdc5cfcc7bc1fa19d301f02eca15",
|
||||
"bytes": 1823
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/FindingsView.tsx",
|
||||
"sha256": "16a6ebf6f19aa4141b0d025f5a517993ca90163965752287b079e5ca4b69b97d",
|
||||
"bytes": 4358
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/HistoryView.tsx",
|
||||
"sha256": "d17d7078d99de3103a6390927bf8daf0b096873df1eb253e24b80d18bcd2f592",
|
||||
"bytes": 4304
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/HotspotsView.tsx",
|
||||
"sha256": "829dc513d951744481ce2ddfa3ef9ef0478e16500bb1c486dd7dcbf24dbbbe52",
|
||||
"bytes": 1714
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/OverviewView.tsx",
|
||||
"sha256": "64d7fdd604f262418d3802e5a69ec4750e0c04c2a5cc310a73be2e2f8dec0072",
|
||||
"bytes": 10146
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/PortfolioAtlas.test.tsx",
|
||||
"sha256": "9befda03b64aff43f05b134c26633c5df54d3e8fb6db036abf5eb33509936324",
|
||||
"bytes": 1252
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/PortfolioAtlas.tsx",
|
||||
"sha256": "f647dc2405a4d6f049ce4cf6296774061ce60944ef3bdaa243445d785bce94e8",
|
||||
"bytes": 5256
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/RemediationCenter.tsx",
|
||||
"sha256": "60b1aaeae17a3b60ded5b3e76201663120ce86af35e6b7f1935ae86e4b69c715",
|
||||
"bytes": 2338
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/RepositoryVisual.test.tsx",
|
||||
"sha256": "68c1b2caf1dfb12ad6a2bdc0b8469afd0b18b0efe17a1c0eaf7c95904465d083",
|
||||
"bytes": 961
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/RepositoryVisual.tsx",
|
||||
"sha256": "ec830882de931e0c7d4314cf8f35e80a727fae5ed5fc1b92b3821aaa9e747266",
|
||||
"bytes": 7330
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/components/ScanRunsView.tsx",
|
||||
"sha256": "c8b857efe2492ef0460f77038a0f159a6c5a41f0ca83c619acf56f587c8900b6",
|
||||
"bytes": 1357
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/main.tsx",
|
||||
"sha256": "b3f023da133154adff448b24c256c554072536939e52291490a6128a9cfc82f7",
|
||||
"bytes": 237
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/navigation.test.ts",
|
||||
"sha256": "8afc1406f9642896b9d667e525c7c3d7ff12afc82451f2c41dcc138b2bc15fcd",
|
||||
"bytes": 1026
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/navigation.ts",
|
||||
"sha256": "693b8ff5c2319363bfcc4f8353cfd45ae4c5fad4e9ceae50cbf707aeda24a673",
|
||||
"bytes": 1647
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/styles.css",
|
||||
"sha256": "8cd3bbecd95b579e990c628fac58c00bdedecd66927235440eb01b516b3f7fdb",
|
||||
"bytes": 97641
|
||||
},
|
||||
{
|
||||
"path": "frontend/src/test-fixtures.ts",
|
||||
"sha256": "1fdcd6a69bdcbe2df5ac8666295ea68f6c08e20c0222c477b1eecb5b6ffcce22",
|
||||
"bytes": 5145
|
||||
},
|
||||
{
|
||||
"path": "frontend/tsconfig.json",
|
||||
"sha256": "9616ea859510ecae0cb8464fbe3bbd1448d66bb5a4ebf40d163e8f14fa8fff2b",
|
||||
"bytes": 545
|
||||
},
|
||||
{
|
||||
"path": "frontend/vite.config.ts",
|
||||
"sha256": "f1adfcd5da9ab034690b135c5ba82e14df800296c15347a57549d3a5829e0464",
|
||||
"bytes": 560
|
||||
},
|
||||
{
|
||||
"path": "package-lock.json",
|
||||
"sha256": "f3bfac56e6fd5d2b2752c0f03e3b4c6e71d3ce700c644be60ad3941435053b5b",
|
||||
"bytes": 213
|
||||
},
|
||||
{
|
||||
"path": "package.json",
|
||||
"sha256": "76c07b9209f6c3eeebc34d6562d7f9dc3ade4165734065e267f94db4a2ea29b6",
|
||||
"bytes": 256
|
||||
},
|
||||
{
|
||||
"path": "requirements.txt",
|
||||
"sha256": "c7d062f1bd7b5bd124a3153eab82308d6118a183241327c0b5e2da668e567b33",
|
||||
"bytes": 19
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# RepoPulse
|
||||
|
||||
**Explain repository health, maintenance risk and release readiness from local, deterministic evidence.**
|
||||
|
||||
RepoPulse is a self-hosted Gitea repository intelligence dashboard. It scans exact revisions, keeps findings and history, and helps prioritize the next maintenance action. Source code is analyzed locally without sending it to an external model.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Gitea catalog / local path] --> B[Exact revision acquisition]
|
||||
B --> C[Bounded deterministic scan]
|
||||
C --> D[Findings and explainable scores]
|
||||
D --> E[Portfolio / remediation / history]
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Read-only Gitea repository discovery, default-branch reconciliation and optional signed push webhooks.
|
||||
- Durable SQLite job queue, revision deduplication, lease recovery and detached scan worktrees.
|
||||
- Repository portfolio, triage, quick switch, architecture, hotspots, findings and history views.
|
||||
- Language/LOC inventory, test/CI signals, dependency and lockfile analysis across common ecosystems.
|
||||
- TODO debt, bounded churn, local import graphs and explainable hotspot ranking.
|
||||
- Redacted credential-assignment warnings and backend-owned remediation guidance.
|
||||
- Transparent scan budgets and truncation metadata instead of overstating scan coverage.
|
||||
- Accepted-risk policies, release gates, finding deltas and standalone HTML/JSON reports.
|
||||
|
||||
## Interface preview
|
||||
|
||||
These screenshots show the deterministic browser-test fixture: fictional Alpha/Beta/Gamma repositories, not a private or live repository inventory.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
git clone https://gitea.itworx.tech/Jens/RepoPulse-Public.git
|
||||
cd RepoPulse-Public
|
||||
cp .env.example .env
|
||||
# Select the intended repository directory and configure your Gitea connector.
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open `http://localhost:1254`. Compose runs a Python API and React frontend. `REPOPULSE_SOURCE_ROOT` defaults to the parent directory and is mounted read-only at `/scan`; narrow it to the repository collection you intend to inspect. The Advanced path flow accepts paths inside that boundary.
|
||||
|
||||
`REPOPULSE_DATA_ROOT=./data` in the local template retains SQLite history, queue and repository mirrors. If unset, Compose uses the named `repopulse_data` volume. Keep persisted data outside a replaceable checkout and back it up before upgrades. The durable queue intentionally runs one active scan worker.
|
||||
|
||||
## Connect Gitea
|
||||
|
||||
Configure these values on the server, never in the frontend:
|
||||
|
||||
```dotenv
|
||||
REPOPULSE_GITEA_URL=https://gitea.example.test
|
||||
REPOPULSE_GITEA_TOKEN=<repository-read-token>
|
||||
REPOPULSE_OPERATOR_TOKEN=<independent-operator-token>
|
||||
```
|
||||
|
||||
Use only the repository-read permissions needed for the intended catalog and revisions. An optional `REPOPULSE_GITEA_WEBHOOK_SECRET` authenticates push notifications. RepoPulse discovers repositories, resolves revisions, queues stale work, scans exact SHAs and retains results by revision. Incomplete or failed discovery preserves existing evidence rather than silently declaring missing repositories unavailable.
|
||||
|
||||
Protect deployment with an authenticated proxy or configure `REPOPULSE_OPERATOR_TOKEN`; the mutation API uses `X-RepoPulse-Token`. Limit network access and the scan mount. Direct development without `REPOPULSE_SCAN_ROOT` can read any path accessible to that process.
|
||||
|
||||
## Interpret results
|
||||
|
||||
Findings are deterministic observations with locations and severity. Health scores reflect documented deductions; release gates report **READY**, **READY WITH WARNINGS** or **BLOCKED** under repository policy. Remediation guidance explains confidence, priority and a suggested first action. RepoPulse never edits external repositories automatically.
|
||||
|
||||
Heuristics are engineering guidance, not a substitute for review, runtime tests or specialized security audits. Truncated scans expose their limits. Credential warnings redact values but still require human investigation. Repository history and retained reports contain source-derived metadata, so treat the persisted store as sensitive.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
# Activate .venv for your shell.
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --app-dir backend --reload --port 8000
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci
|
||||
npm --prefix frontend run dev
|
||||
```
|
||||
|
||||
Run `python -m pytest`, `npm --prefix frontend test`, `npm --prefix frontend run typecheck` and `npm --prefix frontend run build`. The browser test fixture uses deterministic API responses; it is not a live production portfolio. See [architecture](docs/ARCHITECTURE.md) for implementation detail.
|
||||
|
||||
## Source distribution and licensing
|
||||
|
||||
This is the curated public source edition of RepoPulse 1.5.0. Reviewed updates flow from the private canonical repository after merges to `main`, without private history, repository inventories, runtime stores or internal CI/deployment workflows. Configure the generic deployment templates for your own environment.
|
||||
|
||||
No project-wide license is included. Public visibility does not grant reuse or redistribution rights; obtain the owner's terms and review third-party dependencies before redistribution.
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
REPOPULSE_DB=/data/repopulse.db \
|
||||
REPOPULSE_SCAN_ROOT=/scan
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/pyproject.toml ./pyproject.toml
|
||||
COPY backend/app ./app
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
VOLUME ["/data", "/scan"]
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1 @@
|
||||
"""RepoPulse backend package."""
|
||||
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Lock, RLock
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .models import Repository
|
||||
|
||||
SAFE_REVISION = re.compile(r"^[0-9a-fA-F]{7,64}$")
|
||||
SAFE_BRANCH = re.compile(r"^[A-Za-z0-9._/-]+$")
|
||||
SAFE_JOB_ID = re.compile(r"^[A-Za-z0-9-]+$")
|
||||
|
||||
|
||||
class AcquisitionError(RuntimeError):
|
||||
def __init__(self, code: str, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcquiredSource:
|
||||
path: Path
|
||||
actual_revision: str
|
||||
branch: str
|
||||
|
||||
|
||||
class GitAcquirer:
|
||||
"""Maintain bounded bare mirrors and scan detached exact-revision worktrees."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_root: str | Path | None = None,
|
||||
token: str | None = None,
|
||||
*,
|
||||
timeout: int = 180,
|
||||
allowed_clone_origin: str | None = None,
|
||||
) -> None:
|
||||
raw_root = cache_root or os.getenv("REPOPULSE_REPOSITORY_CACHE", "data/repos")
|
||||
self.cache_root = Path(raw_root).expanduser().resolve()
|
||||
self.token = token if token is not None else os.getenv("REPOPULSE_GITEA_TOKEN", "")
|
||||
self.timeout = max(10, timeout)
|
||||
configured_origin = allowed_clone_origin or os.getenv("REPOPULSE_GITEA_URL", "")
|
||||
self.allowed_clone_origin = self._origin(configured_origin)
|
||||
self.mirrors_root = self.cache_root / "mirrors"
|
||||
self.worktrees_root = self.cache_root / "worktrees"
|
||||
self.mirrors_root.mkdir(parents=True, exist_ok=True)
|
||||
self.worktrees_root.mkdir(parents=True, exist_ok=True)
|
||||
self._lock_guard = Lock()
|
||||
self._repository_locks: dict[str, RLock] = {}
|
||||
|
||||
@staticmethod
|
||||
def _origin(value: str) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
return None
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
except ValueError:
|
||||
return None
|
||||
return f"{parsed.scheme.casefold()}://{parsed.hostname.casefold()}:{port}"
|
||||
|
||||
def _repository_lock(self, repository_id: str) -> RLock:
|
||||
with self._lock_guard:
|
||||
return self._repository_locks.setdefault(repository_id, RLock())
|
||||
|
||||
def _git_env(self) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
if self.token:
|
||||
env.update(
|
||||
{
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "http.extraHeader",
|
||||
"GIT_CONFIG_VALUE_0": f"Authorization: token {self.token}",
|
||||
}
|
||||
)
|
||||
return env
|
||||
|
||||
def _run(self, args: list[str]) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=self.timeout,
|
||||
env=self._git_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AcquisitionError("timeout", "Git source acquisition timed out") from exc
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
# Git stderr may contain remote URLs or server-specific details; do
|
||||
# not persist or expose it because the job API is user-visible.
|
||||
raise AcquisitionError("git_failed", "Git source acquisition failed") from exc
|
||||
return result.stdout.strip()
|
||||
|
||||
def _mirror_path(self, repository: Repository) -> Path:
|
||||
identity = f"{repository.provider}-{repository.provider_repository_id}"
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]", "_", identity)
|
||||
return self.mirrors_root / f"{safe}.git"
|
||||
|
||||
def _worktree_path(self, job_id: str) -> Path:
|
||||
if not SAFE_JOB_ID.fullmatch(job_id):
|
||||
raise AcquisitionError("invalid_job", "Scan job identity is invalid")
|
||||
return self.worktrees_root / job_id
|
||||
|
||||
@staticmethod
|
||||
def _validate_branch(branch: str) -> None:
|
||||
if (
|
||||
not SAFE_BRANCH.fullmatch(branch)
|
||||
or branch.startswith("-")
|
||||
or ".." in branch
|
||||
or branch.endswith(("/", "."))
|
||||
):
|
||||
raise AcquisitionError("invalid_branch", "Repository default branch is invalid")
|
||||
|
||||
def _validate_clone_url(self, clone_url: str | None) -> str:
|
||||
if not clone_url or "\x00" in clone_url:
|
||||
raise AcquisitionError("missing_clone_url", "Repository clone URL is unavailable")
|
||||
try:
|
||||
parsed = urlsplit(clone_url)
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise AcquisitionError("unsafe_clone_url", "Repository clone URL contains unsafe credentials or metadata")
|
||||
if parsed.scheme in {"http", "https"}:
|
||||
origin = self._origin(clone_url)
|
||||
if self.token and (self.allowed_clone_origin is None or origin != self.allowed_clone_origin):
|
||||
raise AcquisitionError("unsafe_clone_url", "Repository clone URL is outside the configured Gitea origin")
|
||||
elif parsed.scheme != "file":
|
||||
raise AcquisitionError("unsafe_clone_url", "Repository clone URL uses an unsupported scheme")
|
||||
except ValueError as exc:
|
||||
raise AcquisitionError("unsafe_clone_url", "Repository clone URL is invalid") from exc
|
||||
return clone_url
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
repository: Repository,
|
||||
job_id: str,
|
||||
requested_revision: str | None = None,
|
||||
) -> AcquiredSource:
|
||||
with self._repository_lock(repository.id):
|
||||
return self._acquire(repository, job_id, requested_revision)
|
||||
|
||||
def _acquire(
|
||||
self,
|
||||
repository: Repository,
|
||||
job_id: str,
|
||||
requested_revision: str | None = None,
|
||||
) -> AcquiredSource:
|
||||
branch = repository.default_branch or "main"
|
||||
self._validate_branch(branch)
|
||||
if requested_revision is not None and not SAFE_REVISION.fullmatch(requested_revision):
|
||||
raise AcquisitionError("invalid_revision", "Requested revision is not a valid commit identifier")
|
||||
clone_url = self._validate_clone_url(repository.clone_url)
|
||||
mirror = self._mirror_path(repository)
|
||||
if mirror.exists() and not self._mirror_is_usable(mirror):
|
||||
self._remove_mirror_if_owned(mirror)
|
||||
if not mirror.exists():
|
||||
self._run(["git", "clone", "--mirror", clone_url, str(mirror)])
|
||||
else:
|
||||
self._run(["git", "--git-dir", str(mirror), "remote", "set-url", "origin", clone_url])
|
||||
self._run(
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
str(mirror),
|
||||
"fetch",
|
||||
"--prune",
|
||||
"--no-tags",
|
||||
"origin",
|
||||
f"+refs/heads/{branch}:refs/remotes/origin/{branch}",
|
||||
]
|
||||
)
|
||||
if requested_revision:
|
||||
try:
|
||||
actual_revision = self._run(
|
||||
["git", "--git-dir", str(mirror), "rev-parse", f"{requested_revision}^{{commit}}"]
|
||||
)
|
||||
except AcquisitionError as exc:
|
||||
raise AcquisitionError("revision_unavailable", "Requested commit was not found in the mirror") from exc
|
||||
if actual_revision.casefold() != requested_revision.casefold() and len(requested_revision) == 40:
|
||||
raise AcquisitionError("revision_unavailable", "Requested commit was not found in the mirror")
|
||||
else:
|
||||
actual_revision = self._run(
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
str(mirror),
|
||||
"rev-parse",
|
||||
f"refs/remotes/origin/{branch}^{{commit}}",
|
||||
]
|
||||
)
|
||||
if not SAFE_REVISION.fullmatch(actual_revision):
|
||||
raise AcquisitionError("invalid_revision", "Git did not return a usable commit identifier")
|
||||
|
||||
worktree = self._worktree_path(job_id)
|
||||
self._remove_worktree_if_owned(mirror, worktree)
|
||||
try:
|
||||
self._run(
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
str(mirror),
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
str(worktree),
|
||||
actual_revision,
|
||||
]
|
||||
)
|
||||
except AcquisitionError:
|
||||
self._remove_worktree_if_owned(mirror, worktree)
|
||||
raise
|
||||
return AcquiredSource(path=worktree, actual_revision=actual_revision, branch=branch)
|
||||
|
||||
def cleanup(self, source: AcquiredSource, repository: Repository) -> None:
|
||||
with self._repository_lock(repository.id):
|
||||
mirror = self._mirror_path(repository)
|
||||
self._remove_worktree_if_owned(mirror, source.path)
|
||||
|
||||
def _mirror_is_usable(self, mirror: Path) -> bool:
|
||||
try:
|
||||
return self._run(["git", "--git-dir", str(mirror), "rev-parse", "--is-bare-repository"]) == "true"
|
||||
except AcquisitionError:
|
||||
return False
|
||||
|
||||
def _remove_mirror_if_owned(self, mirror: Path) -> None:
|
||||
try:
|
||||
relative = mirror.resolve().relative_to(self.mirrors_root.resolve())
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
raise AcquisitionError("unsafe_mirror", "Repository mirror path escaped the repository cache")
|
||||
if not relative.parts:
|
||||
raise AcquisitionError("unsafe_mirror", "Repository mirror path is invalid")
|
||||
if mirror.is_dir() and not mirror.is_symlink():
|
||||
shutil.rmtree(mirror)
|
||||
elif mirror.exists() or mirror.is_symlink():
|
||||
mirror.unlink()
|
||||
|
||||
def _remove_worktree_if_owned(self, mirror: Path, worktree: Path) -> None:
|
||||
try:
|
||||
relative = worktree.resolve().relative_to(self.worktrees_root.resolve())
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
raise AcquisitionError("unsafe_worktree", "Worktree path escaped the repository cache")
|
||||
if not relative.parts:
|
||||
raise AcquisitionError("unsafe_worktree", "Worktree path is invalid")
|
||||
if worktree.exists():
|
||||
try:
|
||||
self._run(["git", "--git-dir", str(mirror), "worktree", "remove", "--force", str(worktree)])
|
||||
except AcquisitionError:
|
||||
# A stale registration must not prevent a safe bounded cleanup.
|
||||
pass
|
||||
if worktree.exists():
|
||||
shutil.rmtree(worktree)
|
||||
try:
|
||||
self._run(["git", "--git-dir", str(mirror), "worktree", "prune"])
|
||||
except AcquisitionError:
|
||||
pass
|
||||
@@ -0,0 +1,340 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from .models import ArchitectureEdge, ArchitectureGraph, ArchitectureNode, Hotspot
|
||||
|
||||
JS_IMPORT_RE = re.compile(
|
||||
r"(?:\bfrom\s+|\bimport\s*|\brequire\(\s*|\bimport\(\s*)['\"](?P<target>[^'\"]+)['\"]"
|
||||
)
|
||||
JS_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
||||
SOURCE_ROOTS = {"src", "app", "backend", "frontend", "lib", "packages"}
|
||||
CONFIG_IGNORED_DIRS = {".git", ".venv", "node_modules", "dist", "build", "coverage", "target", "vendor"}
|
||||
|
||||
|
||||
def _bounded_config_files(root: Path, names: set[str], *, maximum: int = 20) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for current, directories, candidates in os.walk(root, followlinks=False):
|
||||
directories[:] = [name for name in directories if name.casefold() not in CONFIG_IGNORED_DIRS]
|
||||
for name in candidates:
|
||||
if name in names:
|
||||
files.append(Path(current) / name)
|
||||
if len(files) >= maximum:
|
||||
return sorted(files)
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def _ts_path_aliases(root: Path) -> list[tuple[str, str]]:
|
||||
"""Read a small local subset of tsconfig paths without resolving packages or URLs."""
|
||||
|
||||
aliases: list[tuple[str, str]] = []
|
||||
root_resolved = root.resolve()
|
||||
for config in _bounded_config_files(root, {"tsconfig.json", "jsconfig.json"}):
|
||||
try:
|
||||
if config.stat().st_size > 256_000:
|
||||
continue
|
||||
raw = config.read_text(encoding="utf-8")
|
||||
# Most project tsconfigs are JSON. Comments are deliberately not
|
||||
# interpreted as configuration to keep this scanner deterministic.
|
||||
data = json.loads(raw)
|
||||
options = data.get("compilerOptions", {})
|
||||
paths = options.get("paths", {}) if isinstance(options, dict) else {}
|
||||
if not isinstance(paths, dict):
|
||||
continue
|
||||
base_url = options.get("baseUrl", ".") if isinstance(options, dict) else "."
|
||||
base = (config.parent / str(base_url)).resolve()
|
||||
if base != root_resolved and root_resolved not in base.parents:
|
||||
continue
|
||||
for alias, targets in paths.items():
|
||||
if not isinstance(alias, str) or not isinstance(targets, list):
|
||||
continue
|
||||
for target in targets[:5]:
|
||||
if not isinstance(target, str) or target.startswith(("/", "\\")):
|
||||
continue
|
||||
resolved = (base / target.replace("*", "placeholder")).resolve()
|
||||
if resolved != root_resolved and root_resolved not in resolved.parents:
|
||||
continue
|
||||
aliases.append((alias, str((base / target).relative_to(root_resolved)).replace("\\", "/")))
|
||||
except (OSError, ValueError, json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
return aliases[:100]
|
||||
|
||||
|
||||
def _workspace_packages(root: Path) -> dict[str, Path]:
|
||||
packages: dict[str, Path] = {}
|
||||
for manifest in _bounded_config_files(root, {"package.json"}, maximum=100):
|
||||
try:
|
||||
if manifest.stat().st_size > 256_000:
|
||||
continue
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
name = data.get("name") if isinstance(data, dict) else None
|
||||
if isinstance(name, str) and name and not name.startswith((".", "/")):
|
||||
packages.setdefault(name, manifest.parent)
|
||||
except (OSError, ValueError, json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
return packages
|
||||
|
||||
|
||||
def _module_names(path: str) -> list[str]:
|
||||
parts = list(Path(path).with_suffix("").parts)
|
||||
if parts and parts[-1] == "__init__":
|
||||
parts = parts[:-1]
|
||||
names: list[str] = []
|
||||
if parts:
|
||||
names.append(".".join(parts))
|
||||
if parts[0] in SOURCE_ROOTS and len(parts) > 1:
|
||||
names.append(".".join(parts[1:]))
|
||||
if len(parts) > 2 and parts[1] in SOURCE_ROOTS:
|
||||
names.append(".".join(parts[2:]))
|
||||
return [name for name in names if name]
|
||||
|
||||
|
||||
def _python_module_map(paths: list[str]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for path in paths:
|
||||
if not path.endswith(".py"):
|
||||
continue
|
||||
for module in _module_names(path):
|
||||
result.setdefault(module, path)
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_python_target(module: str, module_map: dict[str, str]) -> str | None:
|
||||
candidate = module
|
||||
while candidate:
|
||||
if candidate in module_map:
|
||||
return module_map[candidate]
|
||||
if "." not in candidate:
|
||||
break
|
||||
candidate = candidate.rsplit(".", 1)[0]
|
||||
return None
|
||||
|
||||
|
||||
def _python_imports(path: str, text: str, module_map: dict[str, str]) -> set[str]:
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return set()
|
||||
|
||||
current_names = _module_names(path)
|
||||
current = current_names[0] if current_names else ""
|
||||
current_parts = current.split(".") if current else []
|
||||
if not path.endswith("/__init__.py") and current_parts:
|
||||
package_parts = current_parts[:-1]
|
||||
else:
|
||||
package_parts = current_parts
|
||||
|
||||
imports: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
target = _resolve_python_target(alias.name, module_map)
|
||||
if target and target != path:
|
||||
imports.add(target)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
module = node.module or ""
|
||||
if node.level:
|
||||
trim = max(0, node.level - 1)
|
||||
prefix = package_parts[:-trim] if trim else package_parts
|
||||
absolute = ".".join([*prefix, *([module] if module else [])])
|
||||
else:
|
||||
absolute = module
|
||||
target = _resolve_python_target(absolute, module_map)
|
||||
if target and target != path:
|
||||
imports.add(target)
|
||||
return imports
|
||||
|
||||
|
||||
def _resolve_js_target(
|
||||
root: Path,
|
||||
source_path: str,
|
||||
specifier: str,
|
||||
known: set[str],
|
||||
aliases: list[tuple[str, str]],
|
||||
workspace_packages: dict[str, Path],
|
||||
) -> str | None:
|
||||
if specifier.startswith("."):
|
||||
source_dir = (root / source_path).parent
|
||||
base = (source_dir / specifier).resolve()
|
||||
else:
|
||||
base = None
|
||||
for alias, target in aliases:
|
||||
if alias.endswith("/*") and specifier.startswith(alias[:-1]):
|
||||
base = (root / target.replace("*", specifier[len(alias) - 1 :])).resolve()
|
||||
break
|
||||
if alias == specifier:
|
||||
base = (root / target).resolve()
|
||||
break
|
||||
if base is None:
|
||||
package_name = next(
|
||||
(name for name in workspace_packages if specifier == name or specifier.startswith(f"{name}/")),
|
||||
None,
|
||||
)
|
||||
if package_name:
|
||||
package_root = workspace_packages[package_name]
|
||||
suffix = specifier.removeprefix(package_name).lstrip("/")
|
||||
base = package_root / suffix if suffix else package_root / "src" / "index"
|
||||
if base is None:
|
||||
return None
|
||||
try:
|
||||
relative_base = base.relative_to(root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
candidates = [relative_base]
|
||||
suffix = Path(relative_base).suffix.lower()
|
||||
if suffix in JS_EXTENSIONS:
|
||||
extensionless = relative_base[: -len(suffix)]
|
||||
candidates.extend(extensionless + extension for extension in JS_EXTENSIONS)
|
||||
candidates.extend(f"{extensionless}/index{extension}" for extension in JS_EXTENSIONS)
|
||||
elif not suffix:
|
||||
candidates.extend(relative_base + extension for extension in JS_EXTENSIONS)
|
||||
candidates.extend(f"{relative_base}/index{extension}" for extension in JS_EXTENSIONS)
|
||||
return next((candidate for candidate in candidates if candidate in known), None)
|
||||
|
||||
|
||||
def _js_imports(
|
||||
root: Path,
|
||||
path: str,
|
||||
text: str,
|
||||
known: set[str],
|
||||
aliases: list[tuple[str, str]],
|
||||
workspace_packages: dict[str, Path],
|
||||
) -> set[str]:
|
||||
imports: set[str] = set()
|
||||
for match in JS_IMPORT_RE.finditer(text):
|
||||
target = _resolve_js_target(root, path, match.group("target"), known, aliases, workspace_packages)
|
||||
if target and target != path:
|
||||
imports.add(target)
|
||||
return imports
|
||||
|
||||
|
||||
def build_architecture(
|
||||
root: Path,
|
||||
source_texts: dict[str, str],
|
||||
language_by_path: dict[str, str],
|
||||
loc_by_path: dict[str, int],
|
||||
debt_by_path: dict[str, int],
|
||||
churn_by_path: dict[str, int],
|
||||
*,
|
||||
max_nodes: int = 160,
|
||||
max_edges: int = 600,
|
||||
) -> tuple[ArchitectureGraph, list[Hotspot]]:
|
||||
max_nodes = max(1, max_nodes)
|
||||
max_edges = max(0, max_edges)
|
||||
known_paths = set(source_texts)
|
||||
module_map = _python_module_map(list(known_paths))
|
||||
ts_aliases = _ts_path_aliases(root)
|
||||
workspace_packages = _workspace_packages(root)
|
||||
edge_pairs: set[tuple[str, str]] = set()
|
||||
total_inbound: Counter[str] = Counter()
|
||||
total_outbound: Counter[str] = Counter()
|
||||
total_edges = 0
|
||||
truncated = len(known_paths) > max_nodes
|
||||
|
||||
for path in sorted(source_texts):
|
||||
text = source_texts[path]
|
||||
language = language_by_path.get(path, "")
|
||||
if language == "Python":
|
||||
targets = _python_imports(path, text, module_map)
|
||||
elif language in {"TypeScript", "JavaScript"}:
|
||||
targets = _js_imports(root, path, text, known_paths, ts_aliases, workspace_packages)
|
||||
else:
|
||||
targets = set()
|
||||
for target in sorted(targets):
|
||||
if target not in known_paths:
|
||||
continue
|
||||
pair = (path, target)
|
||||
total_edges += 1
|
||||
total_outbound[path] += 1
|
||||
total_inbound[target] += 1
|
||||
if len(edge_pairs) < max_edges:
|
||||
edge_pairs.add(pair)
|
||||
else:
|
||||
truncated = True
|
||||
|
||||
hotspots: list[Hotspot] = []
|
||||
for path in known_paths:
|
||||
loc = loc_by_path.get(path, 0)
|
||||
debt = debt_by_path.get(path, 0)
|
||||
churn = churn_by_path.get(path, 0)
|
||||
coupling = total_inbound[path] + total_outbound[path]
|
||||
score = min(
|
||||
100,
|
||||
round(
|
||||
min(30, loc / 40)
|
||||
+ min(20, debt * 4)
|
||||
+ min(30, math.log10(churn + 1) * 10 if churn else 0)
|
||||
+ min(20, coupling * 2.5)
|
||||
),
|
||||
)
|
||||
reasons: list[str] = []
|
||||
if loc >= 500:
|
||||
reasons.append(f"large module ({loc} LOC)")
|
||||
if debt:
|
||||
reasons.append(f"{debt} debt markers")
|
||||
if churn >= 300:
|
||||
reasons.append(f"high recent churn ({churn} changed lines)")
|
||||
if coupling >= 8:
|
||||
reasons.append(f"high coupling ({coupling} local edges)")
|
||||
if not reasons and score >= 30:
|
||||
reasons.append("combined size, churn and coupling")
|
||||
hotspots.append(
|
||||
Hotspot(
|
||||
path=path,
|
||||
language=language_by_path.get(path, "Other"),
|
||||
lines_of_code=loc,
|
||||
debt_markers=debt,
|
||||
churn=churn,
|
||||
inbound=total_inbound[path],
|
||||
outbound=total_outbound[path],
|
||||
score=score,
|
||||
reasons=reasons,
|
||||
)
|
||||
)
|
||||
|
||||
hotspots.sort(key=lambda item: (-item.score, -item.churn, -item.lines_of_code, item.path))
|
||||
selected_paths = {item.path for item in hotspots[:max_nodes]}
|
||||
graph_edges = [
|
||||
ArchitectureEdge(source=source, target=target)
|
||||
for source, target in sorted(edge_pairs)
|
||||
if source in selected_paths and target in selected_paths
|
||||
][:max_edges]
|
||||
visible_inbound: Counter[str] = Counter()
|
||||
visible_outbound: Counter[str] = Counter()
|
||||
for edge in graph_edges:
|
||||
visible_outbound[edge.source] += 1
|
||||
visible_inbound[edge.target] += 1
|
||||
hotspot_scores = {item.path: item.score for item in hotspots}
|
||||
nodes = [
|
||||
ArchitectureNode(
|
||||
id=path,
|
||||
label=Path(path).name,
|
||||
path=path,
|
||||
language=language_by_path.get(path, "Other"),
|
||||
inbound=total_inbound[path],
|
||||
outbound=total_outbound[path],
|
||||
visible_inbound=visible_inbound[path],
|
||||
visible_outbound=visible_outbound[path],
|
||||
hotspot_score=hotspot_scores[path],
|
||||
)
|
||||
for path in sorted(selected_paths)
|
||||
]
|
||||
clusters = Counter((Path(path).parts[0] if len(Path(path).parts) > 1 else "root") for path in selected_paths)
|
||||
return (
|
||||
ArchitectureGraph(
|
||||
nodes=nodes,
|
||||
edges=graph_edges,
|
||||
clusters=dict(clusters.most_common()),
|
||||
truncated=truncated,
|
||||
total_nodes=len(known_paths),
|
||||
total_edges=total_edges,
|
||||
),
|
||||
hotspots[:30],
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .models import GitSignals
|
||||
|
||||
|
||||
def _run(root: Path, *args: str, timeout: int = 8) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except (OSError, UnicodeError, subprocess.SubprocessError):
|
||||
return None
|
||||
return result.stdout
|
||||
|
||||
|
||||
def analyze_git(root: Path) -> tuple[GitSignals, dict[str, int]]:
|
||||
top_level = _run(root, "rev-parse", "--show-toplevel")
|
||||
if not top_level:
|
||||
return GitSignals(), {}
|
||||
try:
|
||||
if Path(top_level.strip()).resolve() != root.resolve():
|
||||
return GitSignals(), {}
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return GitSignals(), {}
|
||||
|
||||
inside = _run(root, "rev-parse", "--is-inside-work-tree")
|
||||
if not inside or inside.strip() != "true":
|
||||
return GitSignals(), {}
|
||||
|
||||
raw_count = _run(root, "rev-list", "--count", "HEAD")
|
||||
raw_latest = _run(root, "log", "-1", "--format=%aI")
|
||||
raw_authors = _run(root, "log", "--format=%aN%x00%aE", "-n", "2000")
|
||||
raw_files = _run(root, "ls-files", "-z")
|
||||
raw_churn = _run(root, "log", "--numstat", "--format=", "--find-renames=20%", "-n", "200")
|
||||
raw_renames = _run(root, "log", "--name-status", "--format=", "--find-renames=20%", "-n", "200")
|
||||
|
||||
contributors: set[str] = set()
|
||||
if raw_authors:
|
||||
for line in raw_authors.splitlines():
|
||||
if line.strip():
|
||||
contributors.add(line.strip())
|
||||
|
||||
churn: Counter[str] = Counter()
|
||||
renamed_files: set[str] = set()
|
||||
rename_map: dict[str, str] = {}
|
||||
if raw_renames:
|
||||
for line in raw_renames.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3 and parts[0].startswith("R"):
|
||||
old_path, new_path = parts[-2:]
|
||||
rename_map[old_path] = new_path
|
||||
renamed_files.add(new_path)
|
||||
if raw_churn:
|
||||
for line in raw_churn.splitlines():
|
||||
parts = line.split("\t", 2)
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
additions, deletions, path = parts
|
||||
churn_path = _rename_target(path) if " => " in path else rename_map.get(path, path)
|
||||
if " => " in path:
|
||||
renamed_files.add(churn_path)
|
||||
if additions.isdigit():
|
||||
churn[churn_path] += int(additions)
|
||||
if deletions.isdigit():
|
||||
churn[churn_path] += int(deletions)
|
||||
|
||||
latest_commit_at = None
|
||||
if raw_latest and raw_latest.strip():
|
||||
try:
|
||||
latest_commit_at = datetime.fromisoformat(raw_latest.strip())
|
||||
except ValueError:
|
||||
latest_commit_at = None
|
||||
|
||||
return (
|
||||
GitSignals(
|
||||
available=True,
|
||||
commit_count=int(raw_count.strip()) if raw_count and raw_count.strip().isdigit() else None,
|
||||
contributor_count=len(contributors) if contributors else None,
|
||||
latest_commit_at=latest_commit_at,
|
||||
tracked_files=len([item for item in raw_files.split("\0") if item]) if raw_files is not None else None,
|
||||
renamed_files=sorted(renamed_files),
|
||||
),
|
||||
dict(churn),
|
||||
)
|
||||
|
||||
|
||||
def _rename_target(path: str) -> str:
|
||||
"""Normalize Git's brace-formatted rename path to the new path."""
|
||||
if "{" in path and "}" in path:
|
||||
prefix, remainder = path.split("{", 1)
|
||||
inner, suffix = remainder.split("}", 1)
|
||||
if " => " in inner:
|
||||
return f"{prefix}{inner.split(' => ', 1)[1]}{suffix}"
|
||||
return path.rsplit(" => ", 1)[-1]
|
||||
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from .models import Repository, RepositoryPolicy, RepositorySyncResult
|
||||
|
||||
JsonTransport = Callable[[str, dict[str, str]], tuple[int, bytes]]
|
||||
|
||||
|
||||
class GiteaError(RuntimeError):
|
||||
def __init__(self, code: str, detail: str, *, status_code: int | None = None) -> None:
|
||||
super().__init__(detail)
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _parse_time(value: object) -> datetime | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Small, read-only Gitea API client with bounded pagination and safe errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
token: str | None = None,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
page_size: int = 50,
|
||||
max_pages: int = 20,
|
||||
max_repositories: int = 500,
|
||||
max_branch_workers: int | None = None,
|
||||
transport: JsonTransport | None = None,
|
||||
) -> None:
|
||||
self.base_url = (base_url or os.getenv("REPOPULSE_GITEA_URL", "")).rstrip("/")
|
||||
self.token = token if token is not None else os.getenv("REPOPULSE_GITEA_TOKEN", "")
|
||||
self.timeout = max(1.0, timeout)
|
||||
self.page_size = max(1, min(page_size, 100))
|
||||
self.max_pages = max(1, max_pages)
|
||||
self.max_repositories = max(1, max_repositories)
|
||||
if max_branch_workers is None:
|
||||
try:
|
||||
configured_workers = int(os.getenv("REPOPULSE_GITEA_BRANCH_WORKERS", "4"))
|
||||
except ValueError:
|
||||
configured_workers = 4
|
||||
else:
|
||||
configured_workers = max_branch_workers
|
||||
self.max_branch_workers = min(8, max(1, configured_workers))
|
||||
self.transport = transport
|
||||
self._listing_complete = True
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.token)
|
||||
|
||||
def _request_json(self, path: str, params: dict[str, str | int] | None = None) -> Any:
|
||||
if not self.base_url:
|
||||
raise GiteaError("not_configured", "Gitea URL is not configured")
|
||||
query = urllib.parse.urlencode(params or {})
|
||||
url = f"{self.base_url}/api/v1/{path.lstrip('/')}"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "RepoPulse/1.5",
|
||||
}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"token {self.token}"
|
||||
try:
|
||||
if self.transport:
|
||||
status_code, raw = self.transport(url, headers)
|
||||
if status_code >= 400:
|
||||
self._raise_for_status(status_code)
|
||||
else:
|
||||
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
status_code = int(response.status)
|
||||
raw = response.read(2_000_001)
|
||||
if status_code >= 400:
|
||||
self._raise_for_status(status_code)
|
||||
except GiteaError:
|
||||
raise
|
||||
except urllib.error.HTTPError as exc:
|
||||
self._raise_for_status(exc.code)
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise GiteaError("unreachable", "Gitea could not be reached") from exc
|
||||
if not isinstance(raw, bytes):
|
||||
raise GiteaError("invalid_response", "Gitea returned an invalid response body")
|
||||
if len(raw) > 2_000_000:
|
||||
raise GiteaError("response_too_large", "Gitea response exceeded the configured bound")
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise GiteaError("invalid_response", "Gitea returned invalid JSON") from exc
|
||||
|
||||
@staticmethod
|
||||
def _raise_for_status(status_code: int) -> None:
|
||||
if status_code in {401, 403}:
|
||||
raise GiteaError("authentication", "Gitea authentication was rejected", status_code=status_code)
|
||||
if status_code == 404:
|
||||
raise GiteaError("not_found", "The requested Gitea resource was not found", status_code=status_code)
|
||||
if status_code == 429:
|
||||
raise GiteaError("rate_limited", "Gitea rate-limited the request", status_code=status_code)
|
||||
if status_code >= 500:
|
||||
raise GiteaError("upstream", "Gitea returned a server error", status_code=status_code)
|
||||
raise GiteaError("request_failed", "Gitea rejected the request", status_code=status_code)
|
||||
|
||||
def _repository_from_payload(self, payload: object) -> Repository:
|
||||
if not isinstance(payload, dict):
|
||||
raise GiteaError("invalid_repository", "Gitea returned an invalid repository record")
|
||||
raw_id = payload.get("id")
|
||||
if isinstance(raw_id, bool) or not isinstance(raw_id, (int, str)) or not str(raw_id).isdigit():
|
||||
raise GiteaError("invalid_repository", "Gitea repository identity is invalid")
|
||||
owner_payload = payload.get("owner")
|
||||
owner = owner_payload.get("login") if isinstance(owner_payload, dict) else None
|
||||
owner = owner or payload.get("owner_name")
|
||||
name = payload.get("name")
|
||||
full_name = payload.get("full_name")
|
||||
if not isinstance(owner, str) or not isinstance(name, str):
|
||||
if isinstance(full_name, str) and "/" in full_name:
|
||||
owner, name = full_name.split("/", 1)
|
||||
else:
|
||||
raise GiteaError("invalid_repository", "Gitea repository name is invalid")
|
||||
full_name = full_name if isinstance(full_name, str) else f"{owner}/{name}"
|
||||
repository_id = str(raw_id)
|
||||
# Do not trust a provider-supplied clone host when constructing the
|
||||
# credential-bearing acquisition URL. The configured Gitea origin is
|
||||
# the only network destination that may receive the server token.
|
||||
clone_url = (
|
||||
f"{self.base_url}/{urllib.parse.quote(owner, safe='')}/"
|
||||
f"{urllib.parse.quote(name, safe='')}.git"
|
||||
)
|
||||
html_url = payload.get("html_url")
|
||||
default_branch = payload.get("default_branch") or "main"
|
||||
return Repository(
|
||||
id=f"gitea:{repository_id}",
|
||||
provider="gitea",
|
||||
provider_repository_id=repository_id,
|
||||
owner=owner,
|
||||
name=name,
|
||||
full_name=full_name,
|
||||
description=payload.get("description") if isinstance(payload.get("description"), str) else None,
|
||||
html_url=html_url if isinstance(html_url, str) else None,
|
||||
clone_url=clone_url if isinstance(clone_url, str) else None,
|
||||
default_branch=str(default_branch),
|
||||
private=bool(payload.get("private", False)),
|
||||
archived=bool(payload.get("archived", False)),
|
||||
latest_revision_at=_parse_time(payload.get("updated_at")),
|
||||
policy=RepositoryPolicy(),
|
||||
)
|
||||
|
||||
def list_repositories(self) -> list[Repository]:
|
||||
if not self.configured:
|
||||
raise GiteaError("not_configured", "Gitea connector is not configured")
|
||||
repositories: list[Repository] = []
|
||||
self._listing_complete = False
|
||||
for page in range(1, self.max_pages + 1):
|
||||
payload = self._request_json(
|
||||
"user/repos",
|
||||
{"limit": self.page_size, "page": page, "sort": "updated", "direction": "desc"},
|
||||
)
|
||||
if not isinstance(payload, list):
|
||||
raise GiteaError("invalid_response", "Gitea repository listing was not a list")
|
||||
for item in payload:
|
||||
if len(repositories) >= self.max_repositories:
|
||||
return repositories
|
||||
repositories.append(self._repository_from_payload(item))
|
||||
if len(payload) < self.page_size:
|
||||
self._listing_complete = True
|
||||
break
|
||||
return repositories
|
||||
|
||||
def branch_revision(self, repository: Repository) -> str:
|
||||
owner = urllib.parse.quote(repository.owner, safe="")
|
||||
name = urllib.parse.quote(repository.name, safe="")
|
||||
branch = urllib.parse.quote(repository.default_branch, safe="")
|
||||
payload = self._request_json(f"repos/{owner}/{name}/branches/{branch}")
|
||||
if not isinstance(payload, dict):
|
||||
raise GiteaError("invalid_response", "Gitea branch response was invalid")
|
||||
commit = payload.get("commit")
|
||||
revision = None
|
||||
if isinstance(commit, dict):
|
||||
revision = commit.get("id") or commit.get("sha")
|
||||
if (
|
||||
not isinstance(revision, str)
|
||||
or not 7 <= len(revision) <= 64
|
||||
or not all(char in "0123456789abcdefABCDEF" for char in revision)
|
||||
):
|
||||
raise GiteaError("invalid_revision", "Gitea did not return a usable branch revision")
|
||||
return revision
|
||||
|
||||
def discover_with_errors(self) -> tuple[list[Repository], list[tuple[Repository, str]], list[str]]:
|
||||
"""Resolve branch tips independently so one repository cannot abort a catalog sync."""
|
||||
|
||||
repositories = self.list_repositories()
|
||||
|
||||
def resolve(repository: Repository) -> tuple[Repository, str | None, str | None]:
|
||||
try:
|
||||
return repository, self.branch_revision(repository), None
|
||||
except GiteaError as exc:
|
||||
return repository, None, f"{repository.full_name}: {exc.code}: {exc.detail}"
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(self.max_branch_workers, len(repositories) or 1)) as executor:
|
||||
outcomes = list(executor.map(resolve, repositories))
|
||||
discovered = [
|
||||
(repository.model_copy(update={"latest_revision": revision}), revision)
|
||||
for repository, revision, error in outcomes
|
||||
if revision is not None and error is None
|
||||
]
|
||||
errors = [error for _, _, error in outcomes if error]
|
||||
return repositories, discovered, errors
|
||||
|
||||
def discover(self) -> list[tuple[Repository, str]]:
|
||||
"""Compatibility helper for callers needing only successfully resolved repositories."""
|
||||
|
||||
_, discovered, _ = self.discover_with_errors()
|
||||
return discovered
|
||||
|
||||
def sync(self, store) -> RepositorySyncResult:
|
||||
synced_at = datetime.now(UTC)
|
||||
try:
|
||||
listed, discovered, branch_errors = self.discover_with_errors()
|
||||
except GiteaError as exc:
|
||||
error = f"{exc.code}: {exc.detail}"
|
||||
for existing in store.list_repositories(limit=1000):
|
||||
if existing.provider == "gitea" and existing.available:
|
||||
store.update_repository_sync(existing, synced_at=synced_at, error=error)
|
||||
result = RepositorySyncResult(
|
||||
provider="gitea",
|
||||
errors=[error],
|
||||
synced_at=synced_at,
|
||||
)
|
||||
store.record_sync_result(result)
|
||||
return result
|
||||
created = 0
|
||||
updated = 0
|
||||
listed_ids = {repository.id for repository in listed}
|
||||
seen_ids: set[str] = set()
|
||||
for repository, revision in discovered:
|
||||
seen_ids.add(repository.id)
|
||||
before = store.get_repository(repository.id)
|
||||
merged = repository.model_copy(
|
||||
update={
|
||||
"available": True,
|
||||
"last_synced_at": synced_at,
|
||||
"sync_error": None,
|
||||
"latest_revision": revision,
|
||||
"policy": before.policy if before else RepositoryPolicy(),
|
||||
}
|
||||
)
|
||||
store.upsert_repository(merged)
|
||||
if before is None:
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
for repository in listed:
|
||||
if repository.id in seen_ids:
|
||||
continue
|
||||
before = store.get_repository(repository.id)
|
||||
error = next((item for item in branch_errors if item.startswith(f"{repository.full_name}:")), None)
|
||||
merged = repository.model_copy(
|
||||
update={
|
||||
"available": True,
|
||||
"last_synced_at": synced_at,
|
||||
"sync_error": error or "branch: revision could not be resolved",
|
||||
"latest_revision": before.latest_revision if before else None,
|
||||
"policy": before.policy if before else RepositoryPolicy(),
|
||||
}
|
||||
)
|
||||
store.upsert_repository(merged)
|
||||
if before is None:
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
unavailable = 0
|
||||
if self._listing_complete:
|
||||
for existing in store.list_repositories(limit=1000):
|
||||
if existing.provider == "gitea" and existing.id not in listed_ids and existing.available:
|
||||
store.update_repository_sync(
|
||||
existing.model_copy(update={"available": False}),
|
||||
synced_at=synced_at,
|
||||
error="not_discovered",
|
||||
)
|
||||
unavailable += 1
|
||||
result = RepositorySyncResult(
|
||||
provider="gitea",
|
||||
discovered=len(discovered),
|
||||
created=created,
|
||||
updated=updated,
|
||||
unavailable=unavailable,
|
||||
errors=branch_errors,
|
||||
partial=bool(branch_errors) or not self._listing_complete,
|
||||
branch_failures=len(branch_errors),
|
||||
listing_complete=self._listing_complete,
|
||||
synced_at=synced_at,
|
||||
)
|
||||
store.record_sync_result(result)
|
||||
return result
|
||||
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from threading import Event, Lock, Thread, current_thread
|
||||
from time import monotonic
|
||||
|
||||
from .acquisition import AcquisitionError, GitAcquirer
|
||||
from .gitea import GiteaClient
|
||||
from .models import (
|
||||
Repository,
|
||||
RepositorySyncResult,
|
||||
ScanJob,
|
||||
ScanJobStatus,
|
||||
ScanSource,
|
||||
ScanTrigger,
|
||||
SourceOrigin,
|
||||
)
|
||||
from .scanner import scan_repository
|
||||
from .store import MaintenanceModeError, QueueFullError, ScanStore, job_lease_seconds
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _positive_int(name: str, default: int, *, maximum: int | None = None) -> int:
|
||||
try:
|
||||
value = max(1, int(os.getenv(name, str(default))))
|
||||
except ValueError:
|
||||
value = default
|
||||
return min(value, maximum) if maximum else value
|
||||
|
||||
|
||||
def _positive_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return max(0.2, float(os.getenv(name, str(default))))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
class ScanJobRunner:
|
||||
"""Single-process durable worker and bounded Gitea scheduler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: ScanStore,
|
||||
*,
|
||||
gitea: GiteaClient | None = None,
|
||||
acquirer: GitAcquirer | None = None,
|
||||
poll_seconds: float = 0.5,
|
||||
sync_interval_seconds: float | None = None,
|
||||
max_queue: int | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.gitea = gitea or GiteaClient()
|
||||
self.acquirer = acquirer or GitAcquirer()
|
||||
self.poll_seconds = max(0.2, poll_seconds)
|
||||
self.sync_interval_seconds = sync_interval_seconds or _positive_float(
|
||||
"REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS", 300.0
|
||||
)
|
||||
self.max_queue = max_queue or _positive_int("REPOPULSE_MAX_QUEUED_SCANS", 100, maximum=1000)
|
||||
self._stop = Event()
|
||||
self._thread: Thread | None = None
|
||||
self._last_sync = float("-inf")
|
||||
self._sync_lock = Lock()
|
||||
|
||||
@property
|
||||
def started(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def start(self) -> None:
|
||||
if self.started:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = Thread(target=self._run, name="repopulse-scan-worker", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
thread = self._thread
|
||||
if thread and thread is not current_thread():
|
||||
thread.join(timeout=5)
|
||||
if thread is None or not thread.is_alive():
|
||||
self._thread = None
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
job = self._run_iteration()
|
||||
except MaintenanceModeError:
|
||||
job = None
|
||||
except Exception:
|
||||
self._last_sync = monotonic()
|
||||
LOGGER.exception("RepoPulse worker loop recovered from an unexpected failure")
|
||||
job = None
|
||||
if job is None:
|
||||
self._stop.wait(self.poll_seconds)
|
||||
|
||||
def _run_iteration(self) -> ScanJob | None:
|
||||
"""Perform one scheduler pass unless durable maintenance has paused work."""
|
||||
|
||||
if self.store.maintenance_status()["enabled"]:
|
||||
return None
|
||||
if not self.store.touch_worker_heartbeat_if_active(datetime.now(UTC).isoformat()):
|
||||
return None
|
||||
if self.gitea.configured and monotonic() - self._last_sync >= self.sync_interval_seconds:
|
||||
self.sync_now()
|
||||
if self.store.maintenance_status()["enabled"]:
|
||||
return None
|
||||
return self.process_one()
|
||||
|
||||
def sync_now(self) -> RepositorySyncResult:
|
||||
with self._sync_lock:
|
||||
if self.store.maintenance_status()["enabled"]:
|
||||
raise MaintenanceModeError("RepoPulse is in maintenance mode")
|
||||
if not self.gitea.configured:
|
||||
result = RepositorySyncResult(
|
||||
provider="gitea",
|
||||
errors=["not_configured: Gitea connector is not configured"],
|
||||
synced_at=datetime.now(UTC),
|
||||
)
|
||||
self.store.record_sync_result(result)
|
||||
self._last_sync = monotonic()
|
||||
return result
|
||||
try:
|
||||
result = self.gitea.sync(self.store)
|
||||
except Exception:
|
||||
self._last_sync = monotonic()
|
||||
raise
|
||||
self._last_sync = monotonic()
|
||||
if not result.errors:
|
||||
self._schedule_changed_repositories()
|
||||
return result
|
||||
|
||||
def _schedule_changed_repositories(self) -> None:
|
||||
if self.store.maintenance_status()["enabled"]:
|
||||
return
|
||||
for summary in self.store.list_repositories(limit=1000):
|
||||
if summary.provider != "gitea" or summary.archived or not summary.latest_revision:
|
||||
continue
|
||||
if not summary.policy.auto_scan:
|
||||
continue
|
||||
if summary.last_scan_revision and summary.last_scan_revision == summary.latest_revision:
|
||||
continue
|
||||
try:
|
||||
self.store.enqueue_job(
|
||||
summary.id,
|
||||
summary.latest_revision,
|
||||
ScanTrigger.SCHEDULED,
|
||||
max_queue=self.max_queue,
|
||||
)
|
||||
except (MaintenanceModeError, QueueFullError, KeyError):
|
||||
continue
|
||||
|
||||
def set_maintenance_mode(self, enabled: bool) -> dict[str, object]:
|
||||
"""Serialize maintenance changes with catalog reconciliation and scan scheduling."""
|
||||
|
||||
with self._sync_lock:
|
||||
return self.store.set_maintenance_mode(enabled)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
repository: Repository,
|
||||
*,
|
||||
force: bool = False,
|
||||
trigger: ScanTrigger = ScanTrigger.MANUAL,
|
||||
) -> ScanJob:
|
||||
requested_revision = repository.latest_revision
|
||||
return self.store.enqueue_job(
|
||||
repository.id,
|
||||
requested_revision,
|
||||
trigger,
|
||||
force=force,
|
||||
max_queue=self.max_queue,
|
||||
)
|
||||
|
||||
def process_one(self) -> ScanJob | None:
|
||||
job = self.store.claim_next_job()
|
||||
if job is None:
|
||||
return None
|
||||
try:
|
||||
self._process(job)
|
||||
except Exception:
|
||||
LOGGER.exception("RepoPulse scan job failed outside the isolated job handler")
|
||||
try:
|
||||
self.store.update_job(
|
||||
job.id,
|
||||
status=ScanJobStatus.FAILED,
|
||||
phase="failed",
|
||||
progress=100,
|
||||
error_code="internal_error",
|
||||
error_detail="Unexpected repository analysis failure",
|
||||
message="Repository analysis failed",
|
||||
)
|
||||
except Exception:
|
||||
LOGGER.exception("RepoPulse could not persist an unexpected job failure")
|
||||
return self.store.get_job(job.id)
|
||||
|
||||
class _LeaseHeartbeat:
|
||||
def __init__(self, store: ScanStore, job_id: str) -> None:
|
||||
self.store = store
|
||||
self.job_id = job_id
|
||||
self.stop_event = Event()
|
||||
self.thread = Thread(target=self._run, name=f"repopulse-lease-{job_id[:8]}", daemon=True)
|
||||
|
||||
def start(self) -> None:
|
||||
self.thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
def _run(self) -> None:
|
||||
interval = max(1.0, job_lease_seconds() / 3)
|
||||
while not self.stop_event.wait(interval):
|
||||
try:
|
||||
self.store.renew_job_lease(self.job_id)
|
||||
except Exception:
|
||||
LOGGER.warning("RepoPulse could not renew a scan-job lease", exc_info=True)
|
||||
|
||||
def _process(self, job: ScanJob) -> None:
|
||||
repository = self.store.get_repository(job.repository_id)
|
||||
if repository is None:
|
||||
self.store.update_job(
|
||||
job.id,
|
||||
status=ScanJobStatus.FAILED,
|
||||
phase="failed",
|
||||
progress=100,
|
||||
error_code="repository_not_found",
|
||||
error_detail="Repository catalog entry no longer exists",
|
||||
)
|
||||
return
|
||||
source_path = None
|
||||
acquired = None
|
||||
lease_heartbeat = self._LeaseHeartbeat(self.store, job.id)
|
||||
lease_heartbeat.start()
|
||||
try:
|
||||
if repository.provider != "gitea":
|
||||
source_path = self.store.get_repository_source_path(repository.id)
|
||||
if not source_path:
|
||||
raise AcquisitionError("source_unavailable", "Local repository source is unavailable")
|
||||
actual_revision = job.requested_revision
|
||||
branch = repository.default_branch
|
||||
else:
|
||||
acquired = self.acquirer.acquire(repository, job.id, job.requested_revision)
|
||||
source_path = str(acquired.path)
|
||||
actual_revision = acquired.actual_revision
|
||||
branch = acquired.branch
|
||||
self.store.update_job(
|
||||
job.id,
|
||||
status=ScanJobStatus.SCANNING,
|
||||
phase="scanning",
|
||||
progress=30,
|
||||
actual_revision=actual_revision,
|
||||
message="Running deterministic repository analysis",
|
||||
)
|
||||
source = ScanSource(
|
||||
origin=SourceOrigin.GITEA if repository.provider == "gitea" else SourceOrigin.LOCAL,
|
||||
provider=repository.provider,
|
||||
repository_id=repository.id,
|
||||
repository_full_name=repository.full_name,
|
||||
branch=branch,
|
||||
requested_revision=job.requested_revision,
|
||||
actual_revision=actual_revision,
|
||||
trigger=job.trigger,
|
||||
)
|
||||
report = scan_repository(source_path, source=source, display_name=repository.name)
|
||||
self.store.update_job(
|
||||
job.id,
|
||||
status=ScanJobStatus.ANALYZING,
|
||||
phase="analyzing",
|
||||
progress=80,
|
||||
message="Persisting report and comparison baseline",
|
||||
)
|
||||
self.store.update_job(
|
||||
job.id,
|
||||
status=ScanJobStatus.PERSISTING,
|
||||
phase="persisting",
|
||||
progress=90,
|
||||
message="Saving report and revision history",
|
||||
)
|
||||
saved = self.store.save(report)
|
||||
self.store.upsert_repository(repository.model_copy(update={"policy": saved.policy}))
|
||||
self.store.update_job(
|
||||
job.id,
|
||||
status=ScanJobStatus.COMPLETED,
|
||||
phase="completed",
|
||||
progress=100,
|
||||
actual_revision=saved.source.actual_revision,
|
||||
scan_id=saved.id,
|
||||
message="Scan completed",
|
||||
)
|
||||
except AcquisitionError as exc:
|
||||
self.store.update_job(
|
||||
job.id,
|
||||
status=ScanJobStatus.FAILED,
|
||||
phase="failed",
|
||||
progress=100,
|
||||
error_code=exc.code,
|
||||
error_detail=exc.detail,
|
||||
message="Repository source acquisition failed",
|
||||
)
|
||||
except (ValueError, PermissionError, OSError) as exc:
|
||||
self.store.update_job(
|
||||
job.id,
|
||||
status=ScanJobStatus.FAILED,
|
||||
phase="failed",
|
||||
progress=100,
|
||||
error_code="scan_failed",
|
||||
error_detail=str(exc) if isinstance(exc, ValueError) else "Repository analysis failed",
|
||||
message="Repository analysis failed",
|
||||
)
|
||||
except Exception:
|
||||
LOGGER.exception("RepoPulse scan job failed without exposing job details")
|
||||
self.store.update_job(
|
||||
job.id,
|
||||
status=ScanJobStatus.FAILED,
|
||||
phase="failed",
|
||||
progress=100,
|
||||
error_code="internal_error",
|
||||
error_detail="Unexpected repository analysis failure",
|
||||
message="Repository analysis failed",
|
||||
)
|
||||
finally:
|
||||
lease_heartbeat.stop()
|
||||
if acquired is not None and repository is not None:
|
||||
try:
|
||||
self.acquirer.cleanup(acquired, repository)
|
||||
except AcquisitionError:
|
||||
LOGGER.warning("Unable to clean one RepoPulse worktree")
|
||||
@@ -0,0 +1,491 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from threading import BoundedSemaphore, Lock
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request, Response, status
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .acquisition import GitAcquirer
|
||||
from .gitea import GiteaClient
|
||||
from .jobs import ScanJobRunner
|
||||
from .models import (
|
||||
RepositoryDetail,
|
||||
RepositoryScanRequest,
|
||||
RepositorySummary,
|
||||
RepositorySyncResult,
|
||||
ScanComparison,
|
||||
ScanJob,
|
||||
ScanReport,
|
||||
ScanRequest,
|
||||
ScanSummary,
|
||||
ScanTrigger,
|
||||
TrendPoint,
|
||||
WebhookResponse,
|
||||
)
|
||||
from .reporting import content_disposition, render_html_report, safe_report_filename
|
||||
from .scanner import scan_repository
|
||||
from .store import MaintenanceModeError, QueueFullError, ScanStore
|
||||
|
||||
VERSION = "1.5.0"
|
||||
store = ScanStore()
|
||||
SCAN_ROOT = os.getenv("REPOPULSE_SCAN_ROOT")
|
||||
|
||||
|
||||
def _positive_int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv(name, str(default))))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
# The durable queue has one real worker. Keep direct legacy path scans at the
|
||||
# same capacity rather than advertising concurrency that cannot be delivered.
|
||||
REQUESTED_MAX_CONCURRENT_SCANS = _positive_int_env("REPOPULSE_MAX_CONCURRENT_SCANS", 1)
|
||||
MAX_CONCURRENT_SCANS = 1
|
||||
SCAN_SLOTS = BoundedSemaphore(MAX_CONCURRENT_SCANS)
|
||||
gitea_client = GiteaClient()
|
||||
repository_acquirer = GitAcquirer()
|
||||
job_runner: ScanJobRunner | None = None
|
||||
_api_mutation_lock = Lock()
|
||||
_MAINTENANCE_ENTER_PATH = "/api/v1/operator/maintenance/enter"
|
||||
_MAINTENANCE_EXIT_PATH = "/api/v1/operator/maintenance/exit"
|
||||
|
||||
|
||||
def _get_runner() -> ScanJobRunner:
|
||||
global job_runner
|
||||
if job_runner is None or job_runner.store is not store:
|
||||
job_runner = ScanJobRunner(
|
||||
store,
|
||||
gitea=gitea_client,
|
||||
acquirer=repository_acquirer,
|
||||
)
|
||||
return job_runner
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
runner = _get_runner()
|
||||
runner.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
runner.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="RepoPulse API", version=VERSION, lifespan=lifespan)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def maintenance_mutation_gate(request: Request, call_next):
|
||||
"""Serialize external API writes with maintenance transitions and reject writes while paused."""
|
||||
|
||||
if not request.url.path.startswith("/api/v1/") or request.method in {"GET", "HEAD", "OPTIONS"}:
|
||||
return await call_next(request)
|
||||
if request.url.path in {_MAINTENANCE_ENTER_PATH, _MAINTENANCE_EXIT_PATH}:
|
||||
return await call_next(request)
|
||||
|
||||
await run_in_threadpool(_api_mutation_lock.acquire)
|
||||
try:
|
||||
if store.maintenance_status()["enabled"]:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
content={"detail": "RepoPulse is in maintenance mode"},
|
||||
headers={"Retry-After": "30"},
|
||||
)
|
||||
return await call_next(request)
|
||||
finally:
|
||||
_api_mutation_lock.release()
|
||||
|
||||
|
||||
def _maintenance_snapshot() -> dict[str, object]:
|
||||
mode = store.maintenance_status()
|
||||
stats = store.queue_stats()
|
||||
queued = int(stats["queued"])
|
||||
active = int(stats["active"])
|
||||
return {
|
||||
**mode,
|
||||
"queue_depth": queued,
|
||||
"active_jobs": active,
|
||||
"active_drained": active == 0,
|
||||
"drained": queued == 0 and active == 0,
|
||||
}
|
||||
|
||||
|
||||
def _require_mutation_authorization(request: Request, *, strict: bool = False) -> None:
|
||||
"""Use an operator token when configured; deployment perimeter remains explicit otherwise."""
|
||||
|
||||
token = os.getenv("REPOPULSE_OPERATOR_TOKEN", "")
|
||||
if strict:
|
||||
token = os.getenv("REPOPULSE_MAINTENANCE_TOKEN", "") or token
|
||||
if not token:
|
||||
if strict:
|
||||
raise HTTPException(status_code=503, detail="Operator token is not configured")
|
||||
return
|
||||
supplied = request.headers.get("x-repopulse-token", "")
|
||||
if not hmac.compare_digest(supplied, token):
|
||||
raise HTTPException(status_code=401, detail="Operator authorization is required")
|
||||
|
||||
|
||||
def _resolve_scan_path(raw_path: str) -> Path:
|
||||
supplied = Path(raw_path).expanduser()
|
||||
if SCAN_ROOT:
|
||||
root = Path(SCAN_ROOT).expanduser().resolve()
|
||||
candidate = (root / supplied).resolve() if not supplied.is_absolute() else supplied.resolve()
|
||||
if candidate != root and root not in candidate.parents:
|
||||
raise ValueError(f"Path must be inside configured scan root {root}")
|
||||
return candidate
|
||||
return supplied.resolve()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
recovery = store.database_recovery_status()
|
||||
status = "degraded" if recovery.get("status") == "degraded_recovered" else "ok"
|
||||
return {"status": status}
|
||||
|
||||
|
||||
@app.get("/api/v1/meta")
|
||||
def meta() -> dict[str, object]:
|
||||
stats = store.queue_stats()
|
||||
repository_states = store.repository_state_counts()
|
||||
last_sync = store.get_state("last_gitea_sync")
|
||||
parsed_sync: object = None
|
||||
if last_sync:
|
||||
try:
|
||||
parsed_sync = json.loads(last_sync)
|
||||
except json.JSONDecodeError:
|
||||
parsed_sync = last_sync
|
||||
return {
|
||||
"name": "RepoPulse",
|
||||
"version": VERSION,
|
||||
"stage": "v1.5.0",
|
||||
"scan_root_restricted": bool(SCAN_ROOT),
|
||||
"scan_root": SCAN_ROOT,
|
||||
"max_concurrent_scans": MAX_CONCURRENT_SCANS,
|
||||
"requested_max_concurrent_scans": REQUESTED_MAX_CONCURRENT_SCANS,
|
||||
"repository_count": store.repository_count(),
|
||||
"repository_states": repository_states,
|
||||
"current_repositories": repository_states.get("current", 0),
|
||||
"stale_repositories": repository_states.get("stale", 0),
|
||||
"queue_depth": stats["queued"],
|
||||
"active_jobs": stats["active"],
|
||||
"completed_jobs": stats["completed"],
|
||||
"failed_jobs": stats["failed"],
|
||||
"worker_heartbeat": stats["worker_heartbeat"],
|
||||
"gitea": {
|
||||
"configured": gitea_client.configured,
|
||||
"url": gitea_client.base_url or None,
|
||||
"last_sync": parsed_sync,
|
||||
},
|
||||
"storage": store.storage_stats(),
|
||||
"database_recovery": store.database_recovery_status(),
|
||||
"maintenance": _maintenance_snapshot(),
|
||||
"mutation_authentication": "operator_token" if os.getenv("REPOPULSE_OPERATOR_TOKEN") else "perimeter_required",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/operator/maintenance")
|
||||
def get_maintenance_status(request: Request) -> dict[str, object]:
|
||||
_require_mutation_authorization(request, strict=True)
|
||||
return _maintenance_snapshot()
|
||||
|
||||
|
||||
@app.post("/api/v1/operator/maintenance/enter")
|
||||
def enter_maintenance(request: Request) -> dict[str, object]:
|
||||
_require_mutation_authorization(request, strict=True)
|
||||
with _api_mutation_lock:
|
||||
if store.maintenance_status()["enabled"]:
|
||||
raise HTTPException(status_code=423, detail="RepoPulse is already in maintenance mode")
|
||||
_get_runner().set_maintenance_mode(True)
|
||||
return _maintenance_snapshot()
|
||||
|
||||
|
||||
@app.post("/api/v1/operator/maintenance/exit")
|
||||
def exit_maintenance(request: Request) -> dict[str, object]:
|
||||
_require_mutation_authorization(request, strict=True)
|
||||
with _api_mutation_lock:
|
||||
_get_runner().set_maintenance_mode(False)
|
||||
return _maintenance_snapshot()
|
||||
|
||||
|
||||
@app.post("/api/v1/scans", response_model=ScanReport, status_code=status.HTTP_201_CREATED)
|
||||
async def create_scan(payload: ScanRequest, request: Request) -> ScanReport:
|
||||
_require_mutation_authorization(request)
|
||||
try:
|
||||
repository_path = _resolve_scan_path(payload.path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
if not SCAN_SLOTS.acquire(blocking=False):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Repository scan capacity is currently in use; retry after the active scan completes",
|
||||
headers={"Retry-After": "2"},
|
||||
)
|
||||
|
||||
try:
|
||||
report = await asyncio.to_thread(scan_repository, str(repository_path))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail="Repository path cannot be read") from exc
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=422, detail="Repository path cannot be scanned") from exc
|
||||
finally:
|
||||
SCAN_SLOTS.release()
|
||||
return store.save(report)
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories", response_model=list[RepositorySummary])
|
||||
def list_repositories(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0, le=100_000),
|
||||
) -> list[RepositorySummary]:
|
||||
return store.list_repositories(limit, offset)
|
||||
|
||||
|
||||
@app.post("/api/v1/repositories/sync", response_model=RepositorySyncResult)
|
||||
async def sync_repositories(request: Request) -> RepositorySyncResult:
|
||||
_require_mutation_authorization(request)
|
||||
if not gitea_client.configured:
|
||||
raise HTTPException(status_code=503, detail="Gitea connector is not configured")
|
||||
try:
|
||||
result = await asyncio.to_thread(_get_runner().sync_now)
|
||||
except MaintenanceModeError as exc:
|
||||
raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc
|
||||
if result.errors and result.branch_failures == 0:
|
||||
raise HTTPException(status_code=502, detail=result.errors[0])
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories/{repository_id}", response_model=RepositoryDetail)
|
||||
def get_repository(repository_id: str) -> RepositoryDetail:
|
||||
repository = store.get_repository_detail(repository_id)
|
||||
if repository is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return repository
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories/{repository_id}/scans", response_model=list[ScanSummary])
|
||||
def list_repository_scans(
|
||||
repository_id: str,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[ScanSummary]:
|
||||
if store.get_repository(repository_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return store.repository_scans(repository_id, limit)
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories/{repository_id}/trend", response_model=list[TrendPoint])
|
||||
def repository_trend(
|
||||
repository_id: str,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[TrendPoint]:
|
||||
if store.get_repository(repository_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return store.trend(repository_id, limit)
|
||||
|
||||
|
||||
@app.post("/api/v1/repositories/{repository_id}/scans", response_model=ScanJob, status_code=202)
|
||||
async def create_repository_scan(
|
||||
request: Request,
|
||||
repository_id: str,
|
||||
payload: RepositoryScanRequest | None = None,
|
||||
) -> ScanJob:
|
||||
_require_mutation_authorization(request)
|
||||
repository = store.get_repository(repository_id)
|
||||
if repository is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
if repository.archived:
|
||||
raise HTTPException(status_code=409, detail="Archived repositories are not scanned")
|
||||
if not repository.available:
|
||||
raise HTTPException(status_code=409, detail="Repository is no longer available to the Gitea connector")
|
||||
if repository.provider == "gitea" and not gitea_client.configured:
|
||||
raise HTTPException(status_code=503, detail="Gitea connector is not configured")
|
||||
try:
|
||||
return _get_runner().enqueue(repository, force=bool(payload and payload.force))
|
||||
except MaintenanceModeError as exc:
|
||||
raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc
|
||||
except QueueFullError as exc:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=str(exc),
|
||||
headers={"Retry-After": "5"},
|
||||
) from exc
|
||||
|
||||
|
||||
@app.get("/api/v1/scan-jobs/{job_id}", response_model=ScanJob)
|
||||
def get_scan_job(job_id: str) -> ScanJob:
|
||||
job = store.get_job(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Scan job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories/{repository_id}/scans/{scan_id}", response_model=ScanReport)
|
||||
def get_repository_scan(repository_id: str, scan_id: str) -> ScanReport:
|
||||
report = store.get(scan_id)
|
||||
if report is None or report.source.repository_id != repository_id:
|
||||
raise HTTPException(status_code=404, detail="Repository scan not found")
|
||||
return report
|
||||
|
||||
|
||||
@app.post("/api/v1/webhooks/gitea", response_model=WebhookResponse)
|
||||
async def gitea_webhook(request: Request) -> WebhookResponse:
|
||||
secret = os.getenv("REPOPULSE_GITEA_WEBHOOK_SECRET", "")
|
||||
if not secret:
|
||||
raise HTTPException(status_code=404, detail="Gitea webhook integration is not configured")
|
||||
body = await request.body()
|
||||
if len(body) > 2_000_000:
|
||||
raise HTTPException(status_code=413, detail="Webhook payload is too large")
|
||||
signature = request.headers.get("x-gitea-signature", "").removeprefix("sha256=")
|
||||
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
if not signature or not hmac.compare_digest(signature.casefold(), expected.casefold()):
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Webhook payload is invalid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Webhook payload must be an object")
|
||||
repository_payload = payload.get("repository")
|
||||
if not isinstance(repository_payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Webhook repository is missing")
|
||||
raw_id = repository_payload.get("id")
|
||||
repository_id = f"gitea:{raw_id}" if str(raw_id).isdigit() else None
|
||||
repository = store.get_repository(repository_id) if repository_id else None
|
||||
if repository is None:
|
||||
return WebhookResponse(
|
||||
accepted=False,
|
||||
message="Repository is not present in the synchronized catalog",
|
||||
)
|
||||
event = request.headers.get("x-gitea-event", "push").casefold()
|
||||
if event != "push":
|
||||
return WebhookResponse(
|
||||
accepted=True,
|
||||
repository_id=repository.id,
|
||||
message="Non-push webhook acknowledged without scheduling a scan",
|
||||
)
|
||||
ref = payload.get("ref")
|
||||
if ref != f"refs/heads/{repository.default_branch}":
|
||||
return WebhookResponse(
|
||||
accepted=True,
|
||||
repository_id=repository.id,
|
||||
message="Non-default branch push acknowledged without scheduling a scan",
|
||||
)
|
||||
after = payload.get("after")
|
||||
if (
|
||||
not isinstance(after, str)
|
||||
or not 7 <= len(after) <= 64
|
||||
or not all(char in "0123456789abcdefABCDEF" for char in after)
|
||||
or not set(after) - {"0"}
|
||||
):
|
||||
return WebhookResponse(
|
||||
accepted=True,
|
||||
repository_id=repository.id,
|
||||
message="Push acknowledged without a usable commit revision",
|
||||
)
|
||||
if not gitea_client.configured:
|
||||
raise HTTPException(status_code=503, detail="Gitea connector is not configured")
|
||||
updated = repository.model_copy(update={"latest_revision": after, "sync_error": None})
|
||||
store.upsert_repository(updated)
|
||||
try:
|
||||
job = _get_runner().enqueue(updated, trigger=ScanTrigger.WEBHOOK)
|
||||
except MaintenanceModeError as exc:
|
||||
raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc
|
||||
except QueueFullError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc), headers={"Retry-After": "5"}) from exc
|
||||
return WebhookResponse(
|
||||
accepted=True,
|
||||
repository_id=repository.id,
|
||||
job_id=job.id,
|
||||
message="Push accepted and scan scheduled",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/operator/database-recovery/acknowledge", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def acknowledge_database_recovery(request: Request) -> Response:
|
||||
_require_mutation_authorization(request, strict=True)
|
||||
store.acknowledge_database_recovery()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@app.get("/api/v1/scans", response_model=list[ScanReport])
|
||||
def list_scans(limit: int = Query(default=20, ge=1, le=100)) -> list[ScanReport]:
|
||||
return store.recent(limit)
|
||||
|
||||
|
||||
@app.get("/api/v1/scans/{scan_id}", response_model=ScanReport)
|
||||
def get_scan(scan_id: str) -> ScanReport:
|
||||
report = store.get(scan_id)
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
return report
|
||||
|
||||
|
||||
@app.get("/api/v1/scans/{scan_id}/comparison", response_model=ScanComparison)
|
||||
def get_scan_comparison(scan_id: str) -> ScanComparison:
|
||||
report = store.get(scan_id)
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
return store.compare(report)
|
||||
|
||||
|
||||
@app.get("/api/v1/scans/{scan_id}/delta")
|
||||
def get_scan_delta(scan_id: str) -> dict[str, int | None]:
|
||||
report = store.get(scan_id)
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
previous = store.previous_for_repository(
|
||||
report.source.repository_id or report.repository_path,
|
||||
report.id,
|
||||
)
|
||||
return {
|
||||
"previous_health_score": previous.health_score if previous else None,
|
||||
"health_score_delta": report.health_score - previous.health_score if previous else None,
|
||||
"previous_finding_count": len(previous.findings) if previous else None,
|
||||
"finding_count_delta": len(report.findings) - len(previous.findings) if previous else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/trend", response_model=list[TrendPoint])
|
||||
def trend(path: str, limit: int = Query(default=30, ge=1, le=100)) -> list[TrendPoint]:
|
||||
try:
|
||||
resolved = _resolve_scan_path(path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return store.trend(str(resolved), limit)
|
||||
|
||||
|
||||
@app.get("/api/v1/scans/{scan_id}/export")
|
||||
def export_scan(scan_id: str, format: str = Query(default="html", pattern="^(html|json)$")) -> Response:
|
||||
report = store.get(scan_id)
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
if format == "json":
|
||||
filename = safe_report_filename(report.repository_name, "json")
|
||||
return Response(
|
||||
report.model_dump_json(indent=2),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": content_disposition(filename)},
|
||||
)
|
||||
filename = safe_report_filename(report.repository_name, "html")
|
||||
return Response(
|
||||
render_html_report(report),
|
||||
media_type="text/html",
|
||||
headers={"Content-Disposition": content_disposition(filename)},
|
||||
)
|
||||
|
||||
|
||||
static_dir = Path(os.getenv("REPOPULSE_STATIC_DIR", "/app/static"))
|
||||
if static_dir.is_dir():
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend")
|
||||
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from hashlib import sha256
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
BLOCKER = "blocker"
|
||||
|
||||
|
||||
class GateState(StrEnum):
|
||||
PASS = "pass"
|
||||
WARN = "warn"
|
||||
BLOCK = "block"
|
||||
|
||||
|
||||
class SourceOrigin(StrEnum):
|
||||
LOCAL = "local"
|
||||
GITEA = "gitea"
|
||||
|
||||
|
||||
class ScanTrigger(StrEnum):
|
||||
MANUAL = "manual"
|
||||
SCHEDULED = "scheduled"
|
||||
WEBHOOK = "webhook"
|
||||
LOCAL = "local"
|
||||
|
||||
|
||||
class ScanJobStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
ACQUIRING_SOURCE = "acquiring_source"
|
||||
SCANNING = "scanning"
|
||||
ANALYZING = "analyzing"
|
||||
PERSISTING = "persisting"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
SUPERSEDED = "superseded"
|
||||
|
||||
|
||||
class RepositoryState(StrEnum):
|
||||
NEVER_SCANNED = "never_scanned"
|
||||
CURRENT = "current"
|
||||
STALE = "stale"
|
||||
QUEUED = "queued"
|
||||
SCANNING = "scanning"
|
||||
FAILED = "failed"
|
||||
UNAVAILABLE = "unavailable"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class RepositoryPolicy(BaseModel):
|
||||
auto_scan: bool = True
|
||||
min_health_score: int = Field(default=0, ge=0, le=100)
|
||||
max_blockers: int = Field(default=0, ge=0)
|
||||
require_tests: bool = False
|
||||
require_ci: bool = False
|
||||
suppressions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RemediationSignal(BaseModel):
|
||||
"""A compact, redacted finding summary safe for catalog-level triage."""
|
||||
|
||||
code: str
|
||||
category: str
|
||||
severity: Severity
|
||||
message: str
|
||||
count: int = Field(default=1, ge=1)
|
||||
guidance: FindingGuidance | None = None
|
||||
|
||||
|
||||
class ScanSource(BaseModel):
|
||||
origin: SourceOrigin = SourceOrigin.LOCAL
|
||||
provider: str | None = None
|
||||
repository_id: str | None = None
|
||||
repository_full_name: str | None = None
|
||||
branch: str | None = None
|
||||
requested_revision: str | None = None
|
||||
actual_revision: str | None = None
|
||||
trigger: ScanTrigger = ScanTrigger.MANUAL
|
||||
|
||||
|
||||
class Repository(BaseModel):
|
||||
id: str
|
||||
provider: str
|
||||
provider_repository_id: str
|
||||
owner: str
|
||||
name: str
|
||||
full_name: str
|
||||
description: str | None = None
|
||||
html_url: str | None = None
|
||||
clone_url: str | None = None
|
||||
default_branch: str = "main"
|
||||
private: bool = False
|
||||
archived: bool = False
|
||||
available: bool = True
|
||||
latest_revision: str | None = None
|
||||
latest_revision_at: datetime | None = None
|
||||
last_synced_at: datetime | None = None
|
||||
sync_error: str | None = None
|
||||
state: RepositoryState = RepositoryState.NEVER_SCANNED
|
||||
stale: bool = False
|
||||
active_job_id: str | None = None
|
||||
last_scan_id: str | None = None
|
||||
last_scan_at: datetime | None = None
|
||||
last_scan_health: int | None = None
|
||||
last_scan_readiness: str | None = None
|
||||
last_scan_revision: str | None = None
|
||||
policy: RepositoryPolicy = Field(default_factory=RepositoryPolicy)
|
||||
|
||||
|
||||
class RepositorySummary(Repository):
|
||||
"""Catalog row intentionally contains no full scan report."""
|
||||
|
||||
last_scan_blockers: int = 0
|
||||
last_scan_warnings: int = 0
|
||||
last_scan_primary_signal: RemediationSignal | None = None
|
||||
last_scan_health_delta: int | None = None
|
||||
last_scan_added_findings: int = 0
|
||||
last_scan_resolved_findings: int = 0
|
||||
last_scan_priority: int = 0
|
||||
last_scan_priority_rationale: str | None = None
|
||||
|
||||
|
||||
class RepositoryDetail(RepositorySummary):
|
||||
latest_scan: ScanReport | None = None
|
||||
|
||||
|
||||
class ScanJob(BaseModel):
|
||||
id: str
|
||||
repository_id: str
|
||||
status: ScanJobStatus
|
||||
trigger: ScanTrigger
|
||||
requested_revision: str | None = None
|
||||
actual_revision: str | None = None
|
||||
phase: str = "queued"
|
||||
progress: int = Field(default=0, ge=0, le=100)
|
||||
message: str | None = None
|
||||
created_at: datetime
|
||||
started_at: datetime | None = None
|
||||
updated_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
duration_ms: float | None = None
|
||||
scan_id: str | None = None
|
||||
error_code: str | None = None
|
||||
error_detail: str | None = None
|
||||
attempt: int = 0
|
||||
deduplicated: bool = False
|
||||
|
||||
|
||||
class RepositoryScanRequest(BaseModel):
|
||||
force: bool = False
|
||||
|
||||
|
||||
class RepositorySyncResult(BaseModel):
|
||||
provider: str
|
||||
discovered: int = 0
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
unavailable: int = 0
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
partial: bool = False
|
||||
branch_failures: int = 0
|
||||
listing_complete: bool = True
|
||||
synced_at: datetime
|
||||
|
||||
|
||||
class WebhookResponse(BaseModel):
|
||||
accepted: bool
|
||||
repository_id: str | None = None
|
||||
job_id: str | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class FindingGuidance(BaseModel):
|
||||
"""Backend-owned, safe-to-display explanation of a deterministic rule."""
|
||||
|
||||
remediation_class: str
|
||||
confidence: str
|
||||
why: str
|
||||
recommended_action: str
|
||||
priority: int = Field(default=0, ge=0, le=1000)
|
||||
priority_rationale: str
|
||||
|
||||
|
||||
class Finding(BaseModel):
|
||||
code: str
|
||||
category: str
|
||||
severity: Severity
|
||||
message: str
|
||||
path: str | None = None
|
||||
line: int | None = None
|
||||
deduction: int = Field(default=0, ge=0)
|
||||
fingerprint: str | None = None
|
||||
suppressed: bool = False
|
||||
guidance: FindingGuidance | None = None
|
||||
|
||||
|
||||
def finding_fingerprint(finding: Finding) -> str:
|
||||
"""Build a stable semantic identity that ignores line movement and counters."""
|
||||
normalized_message = re.sub(r"\b\d+(?:\.\d+)?\b", "#", finding.message.casefold())
|
||||
payload = "\0".join(
|
||||
(
|
||||
finding.code,
|
||||
finding.category,
|
||||
finding.path or "",
|
||||
normalized_message,
|
||||
)
|
||||
)
|
||||
return sha256(payload.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
class RepositoryMetrics(BaseModel):
|
||||
files: int = 0
|
||||
source_files: int = 0
|
||||
lines_of_code: int = 0
|
||||
test_files: int = 0
|
||||
todo: int = 0
|
||||
fixme: int = 0
|
||||
hack: int = 0
|
||||
xxx: int = 0
|
||||
dependency_manifests: int = 0
|
||||
declared_dependencies: int = 0
|
||||
large_files: int = 0
|
||||
large_source_files: int = 0
|
||||
|
||||
|
||||
class TestSignals(BaseModel):
|
||||
frameworks: list[str] = Field(default_factory=list)
|
||||
coverage_artifacts: list[str] = Field(default_factory=list)
|
||||
ci_workflows: list[str] = Field(default_factory=list)
|
||||
ci_validation_workflows: list[str] = Field(default_factory=list)
|
||||
ci_build_workflows: list[str] = Field(default_factory=list)
|
||||
ci_deploy_only_workflows: list[str] = Field(default_factory=list)
|
||||
coverage_evidence: list[str] = Field(default_factory=list)
|
||||
test_source_ratio: float | None = None
|
||||
|
||||
|
||||
class GitSignals(BaseModel):
|
||||
available: bool = False
|
||||
commit_count: int | None = None
|
||||
contributor_count: int | None = None
|
||||
latest_commit_at: datetime | None = None
|
||||
tracked_files: int | None = None
|
||||
renamed_files: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DependencySignals(BaseModel):
|
||||
manifests: list[str] = Field(default_factory=list)
|
||||
lockfiles: list[str] = Field(default_factory=list)
|
||||
declared_dependencies: int = 0
|
||||
unbounded_dependencies: int = 0
|
||||
unpinned_dependencies: int = 0
|
||||
duplicate_dependencies: list[str] = Field(default_factory=list)
|
||||
included_requirements: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ArchitectureNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
path: str
|
||||
language: str
|
||||
inbound: int = 0
|
||||
outbound: int = 0
|
||||
visible_inbound: int = 0
|
||||
visible_outbound: int = 0
|
||||
hotspot_score: int = Field(default=0, ge=0, le=100)
|
||||
|
||||
|
||||
class ArchitectureEdge(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
kind: str = "import"
|
||||
|
||||
|
||||
class ArchitectureGraph(BaseModel):
|
||||
nodes: list[ArchitectureNode] = Field(default_factory=list)
|
||||
edges: list[ArchitectureEdge] = Field(default_factory=list)
|
||||
clusters: dict[str, int] = Field(default_factory=dict)
|
||||
truncated: bool = False
|
||||
total_nodes: int = 0
|
||||
total_edges: int = 0
|
||||
|
||||
|
||||
class Hotspot(BaseModel):
|
||||
path: str
|
||||
language: str
|
||||
lines_of_code: int
|
||||
debt_markers: int = 0
|
||||
churn: int = 0
|
||||
inbound: int = 0
|
||||
outbound: int = 0
|
||||
score: int = Field(ge=0, le=100)
|
||||
reasons: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GateCheck(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
state: GateState
|
||||
detail: str
|
||||
|
||||
|
||||
class ReleaseGate(BaseModel):
|
||||
status: str
|
||||
checks: list[GateCheck]
|
||||
|
||||
|
||||
class ScanLimits(BaseModel):
|
||||
max_files: int = Field(default=10_000, ge=1, le=100_000)
|
||||
max_text_bytes_per_file: int = Field(default=2_000_000, ge=1_024, le=10_000_000)
|
||||
max_total_text_bytes: int = Field(default=50_000_000, ge=1_000_000, le=500_000_000)
|
||||
max_findings: int = Field(default=2_000, ge=10, le=20_000)
|
||||
max_secret_findings: int = Field(default=20, ge=1, le=500)
|
||||
|
||||
|
||||
class ScanCoverage(BaseModel):
|
||||
limits: ScanLimits
|
||||
discovered_files: int = 0
|
||||
scanned_files: int = 0
|
||||
skipped_files_by_budget: int = 0
|
||||
skipped_files_by_size: int = 0
|
||||
skipped_files_by_text_budget: int = 0
|
||||
text_bytes_scanned: int = 0
|
||||
truncated: bool = False
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ScanReport(BaseModel):
|
||||
id: str
|
||||
repository_path: str
|
||||
repository_name: str
|
||||
source: ScanSource = Field(default_factory=ScanSource)
|
||||
policy: RepositoryPolicy = Field(default_factory=RepositoryPolicy)
|
||||
scanned_at: datetime
|
||||
duration_ms: float
|
||||
metrics: RepositoryMetrics
|
||||
languages: dict[str, int]
|
||||
category_scores: dict[str, int]
|
||||
health_score: int
|
||||
release_readiness: str
|
||||
findings: list[Finding]
|
||||
tests: TestSignals = Field(default_factory=TestSignals)
|
||||
git: GitSignals = Field(default_factory=GitSignals)
|
||||
dependencies: DependencySignals = Field(default_factory=DependencySignals)
|
||||
architecture: ArchitectureGraph = Field(default_factory=ArchitectureGraph)
|
||||
hotspots: list[Hotspot] = Field(default_factory=list)
|
||||
release_gate: ReleaseGate | None = None
|
||||
coverage: ScanCoverage | None = None
|
||||
compacted: bool = False
|
||||
|
||||
|
||||
class ScanSummary(BaseModel):
|
||||
id: str
|
||||
repository_id: str
|
||||
scanned_at: datetime
|
||||
branch: str | None = None
|
||||
requested_revision: str | None = None
|
||||
actual_revision: str | None = None
|
||||
trigger: ScanTrigger = ScanTrigger.MANUAL
|
||||
health_score: int
|
||||
release_readiness: str
|
||||
findings: int
|
||||
duration_ms: float
|
||||
|
||||
|
||||
class ScanRequest(BaseModel):
|
||||
path: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ScanComparison(BaseModel):
|
||||
current_scan_id: str
|
||||
previous_scan_id: str | None = None
|
||||
health_score_delta: int | None = None
|
||||
category_score_deltas: dict[str, int] = Field(default_factory=dict)
|
||||
metric_deltas: dict[str, int] = Field(default_factory=dict)
|
||||
added_findings: list[Finding] = Field(default_factory=list)
|
||||
resolved_findings: list[Finding] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TrendPoint(BaseModel):
|
||||
scan_id: str
|
||||
scanned_at: datetime
|
||||
health_score: int
|
||||
findings: int
|
||||
blockers: int
|
||||
warnings: int
|
||||
debt_markers: int
|
||||
|
||||
|
||||
RepositoryDetail.model_rebuild()
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .models import RepositoryPolicy
|
||||
|
||||
POLICY_NAMES = (".repopulse.yml", ".repopulse.yaml")
|
||||
MAX_POLICY_BYTES = 64_000
|
||||
|
||||
|
||||
def _scalar(value: str) -> bool | int | str:
|
||||
normalized = value.strip().strip('"\'')
|
||||
lowered = normalized.casefold()
|
||||
if lowered in {"true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"false", "no", "off"}:
|
||||
return False
|
||||
try:
|
||||
return int(normalized)
|
||||
except ValueError:
|
||||
return normalized
|
||||
|
||||
|
||||
def load_policy(root: Path) -> RepositoryPolicy:
|
||||
policy_path = next((root / name for name in POLICY_NAMES if (root / name).is_file()), None)
|
||||
if policy_path is None:
|
||||
return RepositoryPolicy()
|
||||
try:
|
||||
if policy_path.stat().st_size > MAX_POLICY_BYTES:
|
||||
return RepositoryPolicy()
|
||||
lines = policy_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return RepositoryPolicy()
|
||||
|
||||
values: dict[str, object] = {}
|
||||
suppressions: list[str] = []
|
||||
in_suppressions = False
|
||||
for raw_line in lines:
|
||||
line = raw_line.split("#", 1)[0].rstrip()
|
||||
if not line.strip():
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if stripped == "suppressions:":
|
||||
in_suppressions = True
|
||||
continue
|
||||
if in_suppressions and stripped.startswith("-"):
|
||||
value = stripped[1:].strip().strip('"\'')
|
||||
if value:
|
||||
suppressions.append(value)
|
||||
continue
|
||||
in_suppressions = False
|
||||
if ":" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split(":", 1)
|
||||
key = key.strip()
|
||||
if key in {"auto_scan", "min_health_score", "max_blockers", "require_tests", "require_ci"}:
|
||||
values[key] = _scalar(value)
|
||||
values["suppressions"] = suppressions
|
||||
try:
|
||||
return RepositoryPolicy.model_validate(values)
|
||||
except ValueError:
|
||||
return RepositoryPolicy()
|
||||
|
||||
|
||||
def apply_policy(report, policy: RepositoryPolicy):
|
||||
findings = [
|
||||
item.model_copy(
|
||||
update={
|
||||
"suppressed": item.suppressed or item.code in set(policy.suppressions),
|
||||
}
|
||||
)
|
||||
for item in report.findings
|
||||
]
|
||||
active_findings = [item for item in findings if not item.suppressed]
|
||||
scores = {category: 100 for category in report.category_scores}
|
||||
for finding in active_findings:
|
||||
scores[finding.category] = max(0, scores[finding.category] - finding.deduction)
|
||||
health_score = round(sum(scores.values()) / len(scores)) if scores else 100
|
||||
from .release_gate import build_release_gate
|
||||
|
||||
release_gate = build_release_gate(
|
||||
health_score=health_score,
|
||||
findings=findings,
|
||||
metrics=report.metrics,
|
||||
tests=report.tests,
|
||||
dependencies=report.dependencies,
|
||||
policy=policy,
|
||||
)
|
||||
return report.model_copy(
|
||||
update={
|
||||
"findings": findings,
|
||||
"category_scores": scores,
|
||||
"health_score": health_score,
|
||||
"release_readiness": release_gate.status,
|
||||
"release_gate": release_gate,
|
||||
"policy": policy,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import (
|
||||
DependencySignals,
|
||||
Finding,
|
||||
GateCheck,
|
||||
GateState,
|
||||
ReleaseGate,
|
||||
RepositoryMetrics,
|
||||
RepositoryPolicy,
|
||||
ScanCoverage,
|
||||
Severity,
|
||||
TestSignals,
|
||||
)
|
||||
|
||||
|
||||
def build_release_gate(
|
||||
*,
|
||||
health_score: int,
|
||||
findings: list[Finding],
|
||||
metrics: RepositoryMetrics,
|
||||
tests: TestSignals,
|
||||
dependencies: DependencySignals,
|
||||
coverage: ScanCoverage | None = None,
|
||||
policy: RepositoryPolicy | None = None,
|
||||
) -> ReleaseGate:
|
||||
policy = policy or RepositoryPolicy()
|
||||
active_findings = [item for item in findings if not item.suppressed]
|
||||
blockers = [item for item in active_findings if item.severity == Severity.BLOCKER]
|
||||
warnings = [item for item in active_findings if item.severity == Severity.WARNING]
|
||||
dependency_issues = dependencies.unbounded_dependencies + dependencies.unpinned_dependencies
|
||||
checks = [
|
||||
GateCheck(
|
||||
key="security",
|
||||
label="Security basics",
|
||||
state=GateState.BLOCK if len(blockers) > policy.max_blockers else GateState.PASS,
|
||||
detail=(
|
||||
f"{len(blockers)} blocker finding(s), policy allows {policy.max_blockers}"
|
||||
if blockers
|
||||
else "No blocker-level secret/environment findings"
|
||||
),
|
||||
),
|
||||
GateCheck(
|
||||
key="tests",
|
||||
label="Tests" + (" (required)" if policy.require_tests else ""),
|
||||
state=(
|
||||
GateState.BLOCK
|
||||
if policy.require_tests and not metrics.test_files
|
||||
else GateState.PASS
|
||||
if metrics.test_files
|
||||
else GateState.WARN
|
||||
),
|
||||
detail=(
|
||||
f"{metrics.test_files} test file(s) detected"
|
||||
if metrics.test_files
|
||||
else "Tests are required by repository policy"
|
||||
if policy.require_tests
|
||||
else "No test files detected"
|
||||
),
|
||||
),
|
||||
GateCheck(
|
||||
key="ci",
|
||||
label="Continuous integration" + (" (required)" if policy.require_ci else ""),
|
||||
state=(
|
||||
GateState.BLOCK
|
||||
if policy.require_ci and not tests.ci_validation_workflows
|
||||
else GateState.PASS
|
||||
if tests.ci_validation_workflows
|
||||
else GateState.WARN
|
||||
),
|
||||
detail=(
|
||||
f"{len(tests.ci_validation_workflows)} validation workflow(s) detected"
|
||||
if tests.ci_validation_workflows
|
||||
else f"{len(tests.ci_deploy_only_workflows)} deploy-only workflow(s) do not provide validation evidence"
|
||||
if tests.ci_deploy_only_workflows
|
||||
else "CI is required by repository policy"
|
||||
if policy.require_ci
|
||||
else "No validation workflow detected"
|
||||
),
|
||||
),
|
||||
GateCheck(
|
||||
key="coverage",
|
||||
label="Coverage evidence",
|
||||
state=(
|
||||
GateState.PASS
|
||||
if tests.coverage_evidence
|
||||
else GateState.WARN
|
||||
if metrics.test_files
|
||||
else GateState.PASS
|
||||
),
|
||||
detail=(
|
||||
f"{len(tests.coverage_evidence)} coverage evidence source(s) detected"
|
||||
if tests.coverage_evidence
|
||||
else "Tests exist but no coverage-producing command or retained coverage output was detected"
|
||||
if metrics.test_files
|
||||
else "No test suite is present to produce coverage evidence"
|
||||
),
|
||||
),
|
||||
GateCheck(
|
||||
key="analysis_coverage",
|
||||
label="Analysis coverage",
|
||||
state=GateState.WARN if coverage and coverage.truncated else GateState.PASS,
|
||||
detail=(
|
||||
"; ".join(coverage.notes) if coverage and coverage.truncated else "Analysis completed within configured limits"
|
||||
),
|
||||
),
|
||||
GateCheck(
|
||||
key="dependencies",
|
||||
label="Dependency hygiene",
|
||||
state=GateState.WARN if dependency_issues else GateState.PASS,
|
||||
detail=(
|
||||
f"{dependencies.unbounded_dependencies} unbounded and {dependencies.unpinned_dependencies} unpinned declaration(s)"
|
||||
if dependency_issues
|
||||
else f"{dependencies.declared_dependencies} dependency declaration(s), no unbounded or unpinned versions"
|
||||
),
|
||||
),
|
||||
GateCheck(
|
||||
key="health",
|
||||
label="Repository health",
|
||||
state=(
|
||||
GateState.PASS
|
||||
if health_score >= max(85, policy.min_health_score)
|
||||
else GateState.WARN
|
||||
if health_score >= max(65, policy.min_health_score)
|
||||
else GateState.BLOCK
|
||||
),
|
||||
detail=(
|
||||
f"Health score {health_score}/100"
|
||||
if not policy.min_health_score
|
||||
else f"Health score {health_score}/100; policy minimum {policy.min_health_score}"
|
||||
),
|
||||
),
|
||||
GateCheck(
|
||||
key="warnings",
|
||||
label="Engineering warnings",
|
||||
state=GateState.WARN if warnings else GateState.PASS,
|
||||
detail=f"{len(warnings)} warning finding(s)" if warnings else "No warning-level findings",
|
||||
),
|
||||
]
|
||||
|
||||
if any(check.state == GateState.BLOCK for check in checks):
|
||||
status = "BLOCKED"
|
||||
elif any(check.state == GateState.WARN for check in checks):
|
||||
status = "READY WITH WARNINGS"
|
||||
else:
|
||||
status = "READY"
|
||||
return ReleaseGate(status=status, checks=checks)
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from html import escape
|
||||
from urllib.parse import quote
|
||||
|
||||
from .models import ScanReport
|
||||
|
||||
INVALID_FILENAME_CHARS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*]')
|
||||
|
||||
REPORT_MARK = """<svg class=\"brand-mark\" viewBox=\"0 0 32 32\" aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"32\" height=\"32\" rx=\"8\" fill=\"#123245\"/><path d=\"M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25\" fill=\"none\" stroke=\"#91ebe0\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"7\" cy=\"9.5\" r=\"2\" fill=\"#91ebe0\"/><circle cx=\"7\" cy=\"22.5\" r=\"2\" fill=\"#91ebe0\"/><circle cx=\"25\" cy=\"16\" r=\"2\" fill=\"#91ebe0\"/></svg>"""
|
||||
|
||||
|
||||
def safe_report_filename(repository_name: str, extension: str) -> str:
|
||||
cleaned = INVALID_FILENAME_CHARS.sub("_", repository_name).strip(" .")
|
||||
cleaned = cleaned[:100].rstrip(" .") or "repository"
|
||||
return f"repopulse-{cleaned}.{extension}"
|
||||
|
||||
|
||||
def content_disposition(filename: str) -> str:
|
||||
fallback = unicodedata.normalize("NFKD", filename).encode("ascii", "ignore").decode("ascii")
|
||||
fallback = INVALID_FILENAME_CHARS.sub("_", fallback).strip(" .") or "repopulse-report"
|
||||
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
|
||||
|
||||
|
||||
def render_html_report(report: ScanReport) -> str:
|
||||
category_rows = "".join(
|
||||
f"<tr><td>{escape(name)}</td><td>{score}/100</td></tr>"
|
||||
for name, score in report.category_scores.items()
|
||||
)
|
||||
finding_rows = "".join(
|
||||
"<tr>"
|
||||
f"<td>{escape(item.severity.value)}</td>"
|
||||
f"<td>{escape(item.category)}</td>"
|
||||
f"<td>{escape(item.message)}</td>"
|
||||
f"<td>{escape(item.path or '')}{':' + str(item.line) if item.line else ''}</td>"
|
||||
"</tr>"
|
||||
for item in report.findings
|
||||
)
|
||||
hotspot_rows = "".join(
|
||||
"<tr>"
|
||||
f"<td>{escape(item.path)}</td><td>{escape(item.language)}</td>"
|
||||
f"<td>{item.score}</td><td>{item.lines_of_code}</td><td>{item.churn}</td>"
|
||||
"</tr>"
|
||||
for item in report.hotspots[:25]
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>RepoPulse report — {escape(report.repository_name)}</title>
|
||||
<style>
|
||||
*{{box-sizing:border-box}}body{{font-family:Inter,ui-sans-serif,system-ui,sans-serif;margin:42px auto;max-width:1120px;color:#172638;line-height:1.45}}
|
||||
.brand{{display:flex;align-items:center;gap:10px;margin-bottom:34px}}.brand-mark{{width:34px;height:34px;flex:0 0 auto}}.brand strong{{display:block;font-size:15px;letter-spacing:-.03em}}.brand small{{display:block;color:#66808d;font-size:8px;font-weight:700;letter-spacing:.13em;text-transform:uppercase}}
|
||||
.hero{{display:flex;align-items:end;justify-content:space-between;gap:20px;padding:23px 25px;border:1px solid #dce8e8;border-radius:18px;background:linear-gradient(120deg,#effafa,#f9fcfc)}}h1{{margin:0 0 4px;font-size:30px;letter-spacing:-.04em}}h2{{margin-top:36px;font-size:18px;letter-spacing:-.02em}}.muted{{color:#607588}}.score{{color:#176c70;font-size:54px;font-weight:750;letter-spacing:-.08em}}.readiness{{color:#176c70;font-size:11px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}}
|
||||
table{{width:100%;border-collapse:collapse;margin:14px 0 34px}}th,td{{text-align:left;padding:10px 9px;border-bottom:1px solid #e4ebef;vertical-align:top}}th{{color:#607588;font-size:10px;letter-spacing:.06em;text-transform:uppercase}}.grid{{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;overflow:hidden;border:1px solid #dce8e8;border-radius:13px;background:#dce8e8}}.card{{padding:15px;background:#fff}}.card strong{{color:#176c70;font-size:24px}}
|
||||
@media print{{body{{margin:18mm;max-width:none}}.hero{{background:#fff}}}}@media(max-width:760px){{.grid{{grid-template-columns:1fr 1fr}}body{{margin:22px}}.hero{{align-items:start;flex-direction:column}}}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brand">{REPORT_MARK}<span><strong>RepoPulse</strong><small>Repository intelligence</small></span></div>
|
||||
<section class="hero"><div><p class="muted">Deterministic repository intelligence report</p><h1>{escape(report.repository_name)}</h1><p class="muted">{escape(report.repository_path)} · {escape(report.scanned_at.isoformat())}</p></div><div><div class="score">{report.health_score}</div><div class="readiness">{escape(report.release_readiness)}</div></div></section>
|
||||
<div class="grid">
|
||||
<div class="card"><strong>{report.metrics.source_files}</strong><br>source files</div>
|
||||
<div class="card"><strong>{report.metrics.lines_of_code}</strong><br>LOC</div>
|
||||
<div class="card"><strong>{report.metrics.test_files}</strong><br>test files</div>
|
||||
<div class="card"><strong>{len(report.findings)}</strong><br>findings</div>
|
||||
</div>
|
||||
<h2>Category scores</h2><table><tbody>{category_rows}</tbody></table>
|
||||
<h2>Hotspots</h2><table><thead><tr><th>Path</th><th>Language</th><th>Score</th><th>LOC</th><th>Churn</th></tr></thead><tbody>{hotspot_rows}</tbody></table>
|
||||
<h2>Findings</h2><table><thead><tr><th>Severity</th><th>Category</th><th>Finding</th><th>Location</th></tr></thead><tbody>{finding_rows}</tbody></table>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -0,0 +1,83 @@
|
||||
from .models import Finding, FindingGuidance, Severity
|
||||
|
||||
_RULES: dict[str, tuple[str, str, str, str]] = {
|
||||
"security.secret-assignment": (
|
||||
"security remediation",
|
||||
"A credential-shaped assignment was found in tracked content.",
|
||||
"Move the value to a secret store or runtime environment and rotate it if it was genuine.",
|
||||
"high",
|
||||
),
|
||||
"security.env-committed": (
|
||||
"security remediation",
|
||||
"A live environment file is tracked, so its contents require deliberate review.",
|
||||
"Remove the live file from version control and retain a sanitized .env.example template.",
|
||||
"high",
|
||||
),
|
||||
"testing.none-detected": (
|
||||
"test coverage",
|
||||
"No conventionally named test source was detected for a repository with source files.",
|
||||
"Add a focused deterministic smoke test, or record a repository policy that explains why tests do not apply.",
|
||||
"medium",
|
||||
),
|
||||
"testing.no-ci": (
|
||||
"delivery evidence",
|
||||
"No repository workflow was found that contains a validation command.",
|
||||
"Add a CI validation workflow that runs the repository's deterministic checks.",
|
||||
"medium",
|
||||
),
|
||||
"dependency.no-lockfile": (
|
||||
"dependency reproducibility",
|
||||
"A JavaScript manifest was found without a lockfile in its package or workspace root.",
|
||||
"Commit the package-manager lockfile used by the build.",
|
||||
"high",
|
||||
),
|
||||
"hygiene.missing-gitignore": (
|
||||
"repository hygiene",
|
||||
"The repository has no root ignore policy.",
|
||||
"Add a minimal .gitignore for generated files, local settings and build outputs.",
|
||||
"high",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _default_rule(finding: Finding) -> tuple[str, str, str, str]:
|
||||
if finding.code.startswith("dependency."):
|
||||
return (
|
||||
"dependency hygiene",
|
||||
"The dependency declaration has a deterministic hygiene signal.",
|
||||
"Review the declared version and lockfile semantics, then make the smallest reproducible correction.",
|
||||
"medium",
|
||||
)
|
||||
if finding.code.startswith("architecture."):
|
||||
return (
|
||||
"architecture review",
|
||||
"The bounded static graph has a structural signal.",
|
||||
"Inspect the affected module boundary before planning a contained refactor.",
|
||||
"medium",
|
||||
)
|
||||
return (
|
||||
"engineering hygiene",
|
||||
"A deterministic repository rule produced this signal.",
|
||||
"Review the retained evidence and make the smallest safe correction.",
|
||||
"medium",
|
||||
)
|
||||
|
||||
|
||||
def enrich_finding(finding: Finding) -> Finding:
|
||||
remediation_class, why, action, confidence = _RULES.get(finding.code, _default_rule(finding))
|
||||
severity_weight = {Severity.BLOCKER: 700, Severity.WARNING: 400, Severity.INFO: 100}[finding.severity]
|
||||
priority = min(1000, severity_weight + finding.deduction * 5)
|
||||
rationale = f"{finding.severity.value.title()} signal; {finding.deduction} health-point impact."
|
||||
guidance = FindingGuidance(
|
||||
remediation_class=remediation_class,
|
||||
confidence=confidence,
|
||||
why=why,
|
||||
recommended_action=action,
|
||||
priority=priority,
|
||||
priority_rationale=rationale,
|
||||
)
|
||||
return finding.model_copy(update={"guidance": guidance})
|
||||
|
||||
|
||||
def enrich_findings(findings: list[Finding]) -> list[Finding]:
|
||||
return [enrich_finding(item) for item in findings]
|
||||
@@ -0,0 +1,695 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from uuid import uuid4
|
||||
|
||||
from .architecture import build_architecture
|
||||
from .git_analysis import analyze_git
|
||||
from .models import (
|
||||
Finding,
|
||||
RepositoryMetrics,
|
||||
RepositoryPolicy,
|
||||
ScanCoverage,
|
||||
ScanLimits,
|
||||
ScanReport,
|
||||
ScanSource,
|
||||
Severity,
|
||||
finding_fingerprint,
|
||||
)
|
||||
from .policy import load_policy
|
||||
from .release_gate import build_release_gate
|
||||
from .rules import enrich_findings
|
||||
from .signals import analyze_dependencies, detect_test_signals
|
||||
|
||||
LANGUAGES = {
|
||||
".py": "Python",
|
||||
".ts": "TypeScript",
|
||||
".tsx": "TypeScript",
|
||||
".js": "JavaScript",
|
||||
".jsx": "JavaScript",
|
||||
".mjs": "JavaScript",
|
||||
".cjs": "JavaScript",
|
||||
".cs": "C#",
|
||||
".java": "Java",
|
||||
".go": "Go",
|
||||
".rs": "Rust",
|
||||
".cpp": "C++",
|
||||
".cc": "C++",
|
||||
".c": "C",
|
||||
".h": "C/C++ Header",
|
||||
".hpp": "C++ Header",
|
||||
".php": "PHP",
|
||||
".rb": "Ruby",
|
||||
".kt": "Kotlin",
|
||||
".swift": "Swift",
|
||||
".vue": "Vue",
|
||||
".svelte": "Svelte",
|
||||
".html": "HTML",
|
||||
".css": "CSS",
|
||||
".scss": "SCSS",
|
||||
".sh": "Shell",
|
||||
".ps1": "PowerShell",
|
||||
".sql": "SQL",
|
||||
}
|
||||
|
||||
IGNORED_DIRS = {
|
||||
".git",
|
||||
".idea",
|
||||
".vscode",
|
||||
".venv",
|
||||
"venv",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"coverage",
|
||||
".next",
|
||||
".nuxt",
|
||||
".pytest_cache",
|
||||
"__pycache__",
|
||||
"vendor",
|
||||
"target",
|
||||
"bin",
|
||||
"obj",
|
||||
}
|
||||
|
||||
MAX_TEXT_BYTES = 2_000_000
|
||||
MAX_SECRET_FINDINGS = 20
|
||||
|
||||
DEBT_PATTERN = re.compile(r"\b(TODO|FIXME|HACK|XXX)\b")
|
||||
SECRET_PATTERN = re.compile(
|
||||
r"""(?ix)
|
||||
(?:
|
||||
^\s*(?:export\s+)?(?:(?:const|let|var|final|static|private|public|protected|internal)\s+)?(?:[A-Za-z_]\w*\.)*
|
||||
|[,{]\s*
|
||||
)
|
||||
[\"']?(?P<name>api[_-]?key|secret|password|passwd|token|access[_-]?key)[\"']?
|
||||
\s*[:=]\s*[\"']?(?P<value>[^\s\"']{8,})
|
||||
"""
|
||||
)
|
||||
PLACEHOLDER_SECRET_MARKERS = ("example", "placeholder", "dummy", "changeme", "not-a-real", "replace-me", "your-", "fake", "test-")
|
||||
SECRET_REFERENCE_MARKERS = (
|
||||
"os.environ",
|
||||
"os.getenv",
|
||||
"getenv",
|
||||
"process.env",
|
||||
"env[",
|
||||
"config.",
|
||||
"settings.",
|
||||
)
|
||||
SECRET_SCAN_CONFIG_SUFFIXES = {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".md", ".txt"}
|
||||
CATEGORY_NAMES = (
|
||||
"Architecture",
|
||||
"Testing",
|
||||
"Maintainability",
|
||||
"Documentation",
|
||||
"Dependencies",
|
||||
"Repository hygiene",
|
||||
"Security basics",
|
||||
)
|
||||
|
||||
|
||||
def _ignored(relative: str) -> bool:
|
||||
return any(part.casefold() in IGNORED_DIRS for part in Path(relative).parts)
|
||||
|
||||
|
||||
def _is_env_template(name: str) -> bool:
|
||||
return name == ".env.example" or (name.startswith(".env.") and name.endswith(".example"))
|
||||
|
||||
|
||||
def _is_secret_scan_candidate(path_obj: Path, relative: str, language: str | None) -> bool:
|
||||
return language is not None or path_obj.suffix.casefold() in SECRET_SCAN_CONFIG_SUFFIXES or path_obj.name.casefold().startswith(".env")
|
||||
|
||||
|
||||
def _bounded_env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
value = int(os.getenv(name, str(default)))
|
||||
except ValueError:
|
||||
value = default
|
||||
return min(maximum, max(minimum, value))
|
||||
|
||||
|
||||
def scan_limits_from_environment() -> ScanLimits:
|
||||
"""Return bounded analysis limits; input cannot turn a scan into an unbounded job."""
|
||||
|
||||
return ScanLimits(
|
||||
max_files=_bounded_env_int("REPOPULSE_SCAN_MAX_FILES", 10_000, minimum=1, maximum=100_000),
|
||||
max_text_bytes_per_file=_bounded_env_int(
|
||||
"REPOPULSE_SCAN_MAX_TEXT_BYTES", MAX_TEXT_BYTES, minimum=1_024, maximum=10_000_000
|
||||
),
|
||||
max_total_text_bytes=_bounded_env_int(
|
||||
"REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES", 50_000_000, minimum=1_000_000, maximum=500_000_000
|
||||
),
|
||||
max_findings=_bounded_env_int("REPOPULSE_SCAN_MAX_FINDINGS", 2_000, minimum=10, maximum=20_000),
|
||||
max_secret_findings=_bounded_env_int(
|
||||
"REPOPULSE_SCAN_MAX_SECRET_FINDINGS", MAX_SECRET_FINDINGS, minimum=1, maximum=500
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _GitIgnoreMatcher:
|
||||
"""Bounded root/nested .gitignore support for non-Git directory scans."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.rules: list[tuple[str, str, bool, bool, bool]] = []
|
||||
for current, dirs, names in os.walk(root, followlinks=False):
|
||||
dirs[:] = [
|
||||
name
|
||||
for name in dirs
|
||||
if name.casefold() not in IGNORED_DIRS and not (Path(current) / name).is_symlink()
|
||||
]
|
||||
if ".gitignore" not in names:
|
||||
continue
|
||||
ignore_file = Path(current) / ".gitignore"
|
||||
try:
|
||||
lines = ignore_file.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
base_path = Path(current).relative_to(root)
|
||||
base = "" if str(base_path) == "." else base_path.as_posix()
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for raw in lines:
|
||||
pattern = raw.strip()
|
||||
if not pattern or pattern.startswith("#"):
|
||||
continue
|
||||
negated = pattern.startswith("!")
|
||||
if negated:
|
||||
pattern = pattern[1:]
|
||||
pattern = pattern.replace("\\", "/")
|
||||
anchored = pattern.startswith("/")
|
||||
pattern = pattern.lstrip("/")
|
||||
dir_only = pattern.endswith("/")
|
||||
pattern = pattern.rstrip("/")
|
||||
if pattern:
|
||||
self.rules.append((base, pattern, negated, dir_only, anchored))
|
||||
|
||||
def is_ignored(self, relative: str, *, is_dir: bool) -> bool:
|
||||
normalized = relative.replace("\\", "/").strip("/")
|
||||
ignored = False
|
||||
for base, pattern, negated, dir_only, anchored in self.rules:
|
||||
if dir_only and not is_dir:
|
||||
continue
|
||||
if base:
|
||||
if normalized == base:
|
||||
target = ""
|
||||
elif normalized.startswith(f"{base}/"):
|
||||
target = normalized[len(base) + 1 :]
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
target = normalized
|
||||
if not target:
|
||||
continue
|
||||
if "/" not in pattern:
|
||||
matched = (
|
||||
fnmatch.fnmatch(target, pattern)
|
||||
if anchored
|
||||
else any(fnmatch.fnmatch(part, pattern) for part in target.split("/")
|
||||
)
|
||||
)
|
||||
else:
|
||||
matched = fnmatch.fnmatch(target, pattern)
|
||||
if not matched and pattern.startswith("**/"):
|
||||
matched = fnmatch.fnmatch(target, pattern[3:])
|
||||
if matched:
|
||||
ignored = not negated
|
||||
return ignored
|
||||
|
||||
|
||||
def _git_worktree_root(root: Path) -> Path | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "rev-parse", "--show-toplevel"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
try:
|
||||
return Path(result.stdout.decode("utf-8", errors="surrogateescape").strip()).resolve()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_file(root: Path, candidate: Path) -> Path | None:
|
||||
"""Return a resolved regular file only when every path component is safe."""
|
||||
try:
|
||||
relative = candidate.relative_to(root)
|
||||
if _ignored(str(relative)):
|
||||
return None
|
||||
current = root
|
||||
for part in Path(relative).parts:
|
||||
current /= part
|
||||
if current.is_symlink():
|
||||
return None
|
||||
resolved = candidate.resolve(strict=True)
|
||||
root_resolved = root.resolve()
|
||||
if resolved == root_resolved or root_resolved not in resolved.parents:
|
||||
return None
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
def _tracked_files(root: Path) -> list[Path] | None:
|
||||
if _git_worktree_root(root) != root:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "ls-files", "-z"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
paths: list[Path] = []
|
||||
for raw in result.stdout.split(b"\0"):
|
||||
if not raw:
|
||||
continue
|
||||
relative = raw.decode("utf-8", errors="surrogateescape")
|
||||
relative_path = Path(relative)
|
||||
if relative_path.is_absolute() or _ignored(relative):
|
||||
continue
|
||||
safe = _safe_file(root, root / relative_path)
|
||||
if safe is not None:
|
||||
paths.append(safe)
|
||||
return paths
|
||||
|
||||
|
||||
def _walk_files(root: Path) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
gitignore = _GitIgnoreMatcher(root)
|
||||
for current, dirs, names in os.walk(root, followlinks=False):
|
||||
current_path = Path(current)
|
||||
dirs[:] = [
|
||||
name
|
||||
for name in dirs
|
||||
if name.casefold() not in IGNORED_DIRS
|
||||
and not (current_path / name).is_symlink()
|
||||
and not gitignore.is_ignored(
|
||||
str((current_path / name).relative_to(root)),
|
||||
is_dir=True,
|
||||
)
|
||||
]
|
||||
for name in names:
|
||||
candidate = current_path / name
|
||||
try:
|
||||
relative = str(candidate.relative_to(root))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if _ignored(relative):
|
||||
continue
|
||||
if gitignore.is_ignored(relative, is_dir=False):
|
||||
continue
|
||||
safe = _safe_file(root, candidate)
|
||||
if safe is not None:
|
||||
files.append(safe)
|
||||
return files
|
||||
|
||||
|
||||
def _read_text(path: Path, max_bytes: int = MAX_TEXT_BYTES) -> str | None:
|
||||
try:
|
||||
if path.stat().st_size > max_bytes:
|
||||
return None
|
||||
raw = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
if b"\0" in raw[:8192]:
|
||||
return None
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _is_test_file(relative: str) -> bool:
|
||||
normalized = relative.replace("\\", "/").lower()
|
||||
name = Path(normalized).name
|
||||
return (
|
||||
"/tests/" in f"/{normalized}"
|
||||
or "/test/" in f"/{normalized}"
|
||||
or name.startswith("test_")
|
||||
or ".test." in name
|
||||
or ".spec." in name
|
||||
or name.endswith(("tests.cs", "test.cs", "_test.go"))
|
||||
)
|
||||
|
||||
|
||||
def _python_function_findings(relative: str, text: str) -> list[Finding]:
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return []
|
||||
findings: list[Finding] = []
|
||||
branch_types = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try, ast.Match, ast.BoolOp)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
end_line = getattr(node, "end_lineno", node.lineno)
|
||||
length = max(1, end_line - node.lineno + 1)
|
||||
branches = sum(1 for child in ast.walk(node) if isinstance(child, branch_types))
|
||||
if length >= 140:
|
||||
findings.append(
|
||||
Finding(
|
||||
code="maintainability.long-function",
|
||||
category="Maintainability",
|
||||
severity=Severity.WARNING,
|
||||
message=f"Function {node.name} spans {length} lines",
|
||||
path=relative,
|
||||
line=node.lineno,
|
||||
deduction=4,
|
||||
)
|
||||
)
|
||||
if branches >= 20:
|
||||
findings.append(
|
||||
Finding(
|
||||
code="maintainability.branch-heavy-function",
|
||||
category="Maintainability",
|
||||
severity=Severity.WARNING,
|
||||
message=f"Function {node.name} contains {branches} branch constructs",
|
||||
path=relative,
|
||||
line=node.lineno,
|
||||
deduction=4,
|
||||
)
|
||||
)
|
||||
return findings[:12]
|
||||
|
||||
|
||||
def _is_placeholder_secret(match: re.Match[str]) -> bool:
|
||||
value = match.group("value").casefold().strip("`$(){}[]<>")
|
||||
exact_placeholder = value in {"", "none", "null", "undefined", "xxx", "xxxxx"}
|
||||
obvious_placeholder = any(value.startswith(marker) for marker in PLACEHOLDER_SECRET_MARKERS)
|
||||
return exact_placeholder or obvious_placeholder or any(
|
||||
value.startswith(marker) for marker in SECRET_REFERENCE_MARKERS
|
||||
)
|
||||
|
||||
|
||||
def scan_repository(
|
||||
path: str,
|
||||
*,
|
||||
source: ScanSource | None = None,
|
||||
display_name: str | None = None,
|
||||
policy: RepositoryPolicy | None = None,
|
||||
limits: ScanLimits | None = None,
|
||||
) -> ScanReport:
|
||||
started = perf_counter()
|
||||
root = Path(path).expanduser().resolve()
|
||||
if not root.exists() or not root.is_dir():
|
||||
raise ValueError("Repository path does not exist or is not a directory")
|
||||
|
||||
limits = limits or scan_limits_from_environment()
|
||||
tracked_files = _tracked_files(root)
|
||||
all_files = _walk_files(root) if tracked_files is None else tracked_files
|
||||
all_files.sort(key=lambda item: str(item.relative_to(root)).replace("\\", "/").casefold())
|
||||
files = all_files[: limits.max_files]
|
||||
coverage = ScanCoverage(
|
||||
limits=limits,
|
||||
discovered_files=len(all_files),
|
||||
skipped_files_by_budget=max(0, len(all_files) - len(files)),
|
||||
truncated=len(all_files) > len(files),
|
||||
notes=(
|
||||
[f"File analysis limited to {limits.max_files} of {len(all_files)} discovered safe files."]
|
||||
if len(all_files) > len(files)
|
||||
else []
|
||||
),
|
||||
)
|
||||
languages: Counter[str] = Counter()
|
||||
findings: list[Finding] = []
|
||||
metrics = RepositoryMetrics(files=len(all_files))
|
||||
files_by_name: dict[str, list[Path]] = {}
|
||||
root_entries = {entry.name.lower() for entry in root.iterdir()}
|
||||
text_by_path: dict[str, str] = {}
|
||||
source_texts: dict[str, str] = {}
|
||||
language_by_path: dict[str, str] = {}
|
||||
loc_by_path: dict[str, int] = {}
|
||||
debt_by_path: dict[str, int] = {}
|
||||
secret_findings = 0
|
||||
secret_findings_truncated = False
|
||||
text_budget_exhausted = False
|
||||
|
||||
for path_obj in files:
|
||||
try:
|
||||
relative = str(path_obj.relative_to(root)).replace("\\", "/")
|
||||
size = path_obj.stat().st_size
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
files_by_name.setdefault(path_obj.name, []).append(path_obj)
|
||||
suffix = path_obj.suffix.lower()
|
||||
language = LANGUAGES.get(suffix)
|
||||
|
||||
if size >= 1_000_000:
|
||||
metrics.large_files += 1
|
||||
findings.append(
|
||||
Finding(
|
||||
code="hygiene.large-file",
|
||||
category="Repository hygiene",
|
||||
severity=Severity.INFO,
|
||||
message=f"Large tracked file ({size / 1_000_000:.1f} MB)",
|
||||
path=relative,
|
||||
deduction=1,
|
||||
)
|
||||
)
|
||||
|
||||
name = path_obj.name.casefold()
|
||||
if name == ".env" or (name.startswith(".env.") and not _is_env_template(name)):
|
||||
findings.append(
|
||||
Finding(
|
||||
code="security.env-committed",
|
||||
category="Security basics",
|
||||
severity=Severity.BLOCKER,
|
||||
message="Environment file is tracked; review it for credentials",
|
||||
path=relative,
|
||||
deduction=30,
|
||||
)
|
||||
)
|
||||
|
||||
if size > limits.max_text_bytes_per_file:
|
||||
coverage.skipped_files_by_size += 1
|
||||
continue
|
||||
if coverage.text_bytes_scanned + size > limits.max_total_text_bytes:
|
||||
coverage.skipped_files_by_text_budget += 1
|
||||
text_budget_exhausted = True
|
||||
continue
|
||||
text = _read_text(path_obj, limits.max_text_bytes_per_file)
|
||||
if text is None:
|
||||
continue
|
||||
coverage.scanned_files += 1
|
||||
coverage.text_bytes_scanned += len(text.encode("utf-8", errors="replace"))
|
||||
text_by_path[relative] = text
|
||||
|
||||
if language:
|
||||
loc = sum(1 for line in text.splitlines() if line.strip())
|
||||
metrics.source_files += 1
|
||||
metrics.lines_of_code += loc
|
||||
languages[language] += loc
|
||||
source_texts[relative] = text
|
||||
language_by_path[relative] = language
|
||||
loc_by_path[relative] = loc
|
||||
if loc > 1000:
|
||||
metrics.large_source_files += 1
|
||||
findings.append(
|
||||
Finding(
|
||||
code="maintainability.large-source",
|
||||
category="Maintainability",
|
||||
severity=Severity.WARNING,
|
||||
message=f"Source file has {loc} non-empty lines",
|
||||
path=relative,
|
||||
deduction=5,
|
||||
)
|
||||
)
|
||||
if language == "Python":
|
||||
findings.extend(_python_function_findings(relative, text))
|
||||
|
||||
if _is_test_file(relative):
|
||||
metrics.test_files += 1
|
||||
|
||||
debt = Counter(DEBT_PATTERN.findall(text))
|
||||
metrics.todo += debt["TODO"]
|
||||
metrics.fixme += debt["FIXME"]
|
||||
metrics.hack += debt["HACK"]
|
||||
metrics.xxx += debt["XXX"]
|
||||
debt_by_path[relative] = sum(debt.values())
|
||||
|
||||
if _is_secret_scan_candidate(path_obj, relative, language) and secret_findings < limits.max_secret_findings:
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
match = SECRET_PATTERN.search(line)
|
||||
if match is None or _is_placeholder_secret(match):
|
||||
continue
|
||||
findings.append(
|
||||
Finding(
|
||||
code="security.secret-assignment",
|
||||
category="Security basics",
|
||||
severity=Severity.BLOCKER,
|
||||
message="Potential credential assignment detected; value intentionally redacted",
|
||||
path=relative,
|
||||
line=line_number,
|
||||
deduction=20,
|
||||
)
|
||||
)
|
||||
secret_findings += 1
|
||||
if secret_findings >= limits.max_secret_findings:
|
||||
secret_findings_truncated = True
|
||||
break
|
||||
|
||||
if text_budget_exhausted:
|
||||
coverage.truncated = True
|
||||
coverage.notes.append(
|
||||
f"Text analysis limited to {limits.max_total_text_bytes} bytes across repository files."
|
||||
)
|
||||
|
||||
if secret_findings_truncated:
|
||||
findings.append(
|
||||
Finding(
|
||||
code="security.secret-findings-truncated",
|
||||
category="Security basics",
|
||||
severity=Severity.INFO,
|
||||
message=f"Potential credential findings are capped at {limits.max_secret_findings}",
|
||||
deduction=0,
|
||||
)
|
||||
)
|
||||
|
||||
if not any(name.startswith("readme") for name in root_entries):
|
||||
findings.append(Finding(code="docs.missing-readme", category="Documentation", severity=Severity.WARNING, message="No root README detected", deduction=20))
|
||||
if not any(name.startswith(("license", "copying")) for name in root_entries):
|
||||
findings.append(Finding(code="docs.missing-license", category="Documentation", severity=Severity.INFO, message="No root license file detected", deduction=5))
|
||||
if ".gitignore" not in root_entries:
|
||||
findings.append(Finding(code="hygiene.missing-gitignore", category="Repository hygiene", severity=Severity.WARNING, message="No root .gitignore detected", deduction=15))
|
||||
if not any(_is_env_template(name) for name in root_entries) and any(name.startswith(".env") for name in root_entries):
|
||||
findings.append(Finding(code="hygiene.missing-env-example", category="Repository hygiene", severity=Severity.INFO, message="Environment configuration exists without a root .env.example", deduction=3))
|
||||
|
||||
test_signals = detect_test_signals(
|
||||
[str(path.relative_to(root)).replace("\\", "/") for path in files],
|
||||
metrics.source_files,
|
||||
metrics.test_files,
|
||||
text_by_path,
|
||||
)
|
||||
if metrics.source_files and metrics.test_files == 0:
|
||||
findings.append(Finding(code="testing.none-detected", category="Testing", severity=Severity.WARNING, message="No test files detected", deduction=30))
|
||||
if not test_signals.ci_workflows:
|
||||
findings.append(Finding(code="testing.no-ci", category="Testing", severity=Severity.WARNING, message="No recognized CI workflow detected", deduction=15))
|
||||
if metrics.test_files and not test_signals.coverage_artifacts:
|
||||
findings.append(Finding(code="testing.no-coverage-artifact", category="Testing", severity=Severity.INFO, message="Tests are present but no coverage artifact is tracked", deduction=2))
|
||||
|
||||
common_source_roots = {"src", "app", "backend", "frontend", "lib", "packages"}
|
||||
if metrics.source_files >= 10 and not (root_entries & common_source_roots):
|
||||
findings.append(Finding(code="architecture.flat-layout", category="Architecture", severity=Severity.INFO, message="No conventional source root detected in a non-trivial repository", deduction=8))
|
||||
|
||||
debt_total = metrics.todo + metrics.fixme + metrics.hack + metrics.xxx
|
||||
if debt_total:
|
||||
findings.append(Finding(code="maintainability.debt-markers", category="Maintainability", severity=Severity.INFO, message=f"Detected {debt_total} TODO/FIXME/HACK/XXX markers", deduction=min(20, max(1, debt_total // 5))))
|
||||
|
||||
dependency_signals, dependency_findings = analyze_dependencies(root, files_by_name)
|
||||
metrics.dependency_manifests = len(dependency_signals.manifests)
|
||||
metrics.declared_dependencies = dependency_signals.declared_dependencies
|
||||
findings.extend(dependency_findings)
|
||||
|
||||
git_signals, churn_by_path = analyze_git(root)
|
||||
architecture, hotspots = build_architecture(
|
||||
root,
|
||||
source_texts,
|
||||
language_by_path,
|
||||
loc_by_path,
|
||||
debt_by_path,
|
||||
churn_by_path,
|
||||
)
|
||||
for hotspot in hotspots[:5]:
|
||||
if hotspot.score >= 70:
|
||||
findings.append(
|
||||
Finding(
|
||||
code="maintainability.hotspot",
|
||||
category="Maintainability",
|
||||
severity=Severity.WARNING,
|
||||
message=f"Engineering hotspot score {hotspot.score}/100: {', '.join(hotspot.reasons) or 'combined risk signals'}",
|
||||
path=hotspot.path,
|
||||
deduction=4,
|
||||
)
|
||||
)
|
||||
|
||||
if architecture.truncated:
|
||||
findings.append(
|
||||
Finding(
|
||||
code="architecture.graph-truncated",
|
||||
category="Architecture",
|
||||
severity=Severity.INFO,
|
||||
message="Architecture graph was bounded to keep analysis responsive",
|
||||
deduction=0,
|
||||
)
|
||||
)
|
||||
|
||||
severity_rank = {Severity.BLOCKER: 0, Severity.WARNING: 1, Severity.INFO: 2}
|
||||
if len(findings) > limits.max_findings:
|
||||
discarded = len(findings) - limits.max_findings
|
||||
findings.sort(key=lambda item: (severity_rank[item.severity], item.category, item.path or "", item.line or 0))
|
||||
findings = findings[: limits.max_findings]
|
||||
findings.append(
|
||||
Finding(
|
||||
code="analysis.findings-truncated",
|
||||
category="Repository hygiene",
|
||||
severity=Severity.INFO,
|
||||
message=f"{discarded} lower-priority findings were omitted by the analysis finding budget",
|
||||
deduction=0,
|
||||
)
|
||||
)
|
||||
coverage.truncated = True
|
||||
coverage.notes.append(f"Finding output limited to {limits.max_findings} deterministic signals.")
|
||||
|
||||
policy = policy or load_policy(root)
|
||||
suppressed_codes = set(policy.suppressions)
|
||||
findings = [
|
||||
item.model_copy(update={"suppressed": item.suppressed or item.code in suppressed_codes})
|
||||
for item in findings
|
||||
]
|
||||
scores = {category: 100 for category in CATEGORY_NAMES}
|
||||
for finding in findings:
|
||||
if not finding.suppressed:
|
||||
scores[finding.category] = max(0, scores[finding.category] - finding.deduction)
|
||||
|
||||
health_score = round(sum(scores.values()) / len(scores))
|
||||
release_gate = build_release_gate(
|
||||
health_score=health_score,
|
||||
findings=findings,
|
||||
metrics=metrics,
|
||||
tests=test_signals,
|
||||
dependencies=dependency_signals,
|
||||
coverage=coverage,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
findings.sort(key=lambda item: (severity_rank[item.severity], item.category, item.path or "", item.line or 0))
|
||||
findings = [
|
||||
item.model_copy(update={"fingerprint": item.fingerprint or finding_fingerprint(item)})
|
||||
for item in findings
|
||||
]
|
||||
findings = enrich_findings(findings)
|
||||
|
||||
return ScanReport(
|
||||
id=str(uuid4()),
|
||||
repository_path=str(root),
|
||||
repository_name=display_name or root.name,
|
||||
source=source or ScanSource(),
|
||||
policy=policy,
|
||||
scanned_at=datetime.now(UTC),
|
||||
duration_ms=round((perf_counter() - started) * 1000, 2),
|
||||
metrics=metrics,
|
||||
languages=dict(languages.most_common()),
|
||||
category_scores=scores,
|
||||
health_score=health_score,
|
||||
release_readiness=release_gate.status,
|
||||
findings=findings,
|
||||
tests=test_signals,
|
||||
git=git_signals,
|
||||
dependencies=dependency_signals,
|
||||
architecture=architecture,
|
||||
hotspots=hotspots,
|
||||
release_gate=release_gate,
|
||||
coverage=coverage,
|
||||
)
|
||||
@@ -0,0 +1,494 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from .models import DependencySignals, Finding, Severity, TestSignals
|
||||
|
||||
LOCKFILES = {
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"bun.lock",
|
||||
"bun.lockb",
|
||||
"uv.lock",
|
||||
"poetry.lock",
|
||||
"Pipfile.lock",
|
||||
"Cargo.lock",
|
||||
"go.sum",
|
||||
}
|
||||
MANIFEST_MAX_BYTES = 2_000_000
|
||||
COVERAGE_NAMES = {
|
||||
"coverage.xml",
|
||||
"lcov.info",
|
||||
"cobertura.xml",
|
||||
".coverage",
|
||||
"jacoco.xml",
|
||||
}
|
||||
COVERAGE_NAMES_CASEFOLD = {item.casefold() for item in COVERAGE_NAMES}
|
||||
|
||||
|
||||
def _python_dependency_name(declaration: str) -> str:
|
||||
return re.split(r"[<>=!~ @;\[]", declaration, maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
def _python_constraint_kind(declaration: str) -> str:
|
||||
"""Classify the useful subset of Python version constraints without penalising bounded ranges."""
|
||||
|
||||
value = declaration.split(";", maxsplit=1)[0].strip().lower()
|
||||
if " @ " in value or "==" in value:
|
||||
return "pinned"
|
||||
if "~=" in value or "^" in value:
|
||||
return "bounded"
|
||||
has_lower = ">" in value
|
||||
has_upper = "<" in value
|
||||
if has_lower and has_upper:
|
||||
return "bounded"
|
||||
return "unbounded"
|
||||
|
||||
|
||||
def _python_dependency_finding(declaration: str, relative: str) -> Finding | None:
|
||||
if _python_constraint_kind(declaration) != "unbounded":
|
||||
return None
|
||||
return Finding(
|
||||
code="dependency.python-unbounded",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message=f"Python dependency has no bounded version range: {declaration.split(';')[0]}",
|
||||
path=relative,
|
||||
deduction=3,
|
||||
)
|
||||
|
||||
|
||||
def detect_test_signals(
|
||||
relative_paths: list[str],
|
||||
source_files: int,
|
||||
test_files: int,
|
||||
text_by_path: dict[str, str],
|
||||
) -> TestSignals:
|
||||
frameworks: set[str] = set()
|
||||
coverage: list[str] = []
|
||||
ci: list[str] = []
|
||||
ci_validation: list[str] = []
|
||||
ci_build: list[str] = []
|
||||
ci_deploy_only: list[str] = []
|
||||
coverage_evidence: list[str] = []
|
||||
|
||||
for path in relative_paths:
|
||||
normalized = path.replace("\\", "/")
|
||||
name = Path(normalized).name
|
||||
name_lower = name.casefold()
|
||||
lower = normalized.lower()
|
||||
if name_lower in COVERAGE_NAMES_CASEFOLD or "/coverage/" in f"/{lower}":
|
||||
coverage.append(normalized)
|
||||
if lower.startswith((".github/workflows/", ".gitea/workflows/")) or name_lower in {
|
||||
".gitlab-ci.yml",
|
||||
"jenkinsfile",
|
||||
"azure-pipelines.yml",
|
||||
}:
|
||||
ci.append(normalized)
|
||||
|
||||
if (name_lower.startswith("test_") or "/tests/" in f"/{lower}") and name_lower.endswith(".py"):
|
||||
frameworks.add("pytest/unittest")
|
||||
if (".test." in name_lower or ".spec." in name_lower) and name_lower.endswith(
|
||||
(".ts", ".tsx", ".js", ".jsx")
|
||||
):
|
||||
frameworks.add("JavaScript test runner")
|
||||
if name_lower.endswith("_test.go"):
|
||||
frameworks.add("go test")
|
||||
if name_lower.endswith(("tests.cs", "test.cs")):
|
||||
frameworks.add(".NET test")
|
||||
|
||||
for path, text in text_by_path.items():
|
||||
name = Path(path).name
|
||||
normalized = path.replace("\\", "/")
|
||||
lowered_text = text.casefold()
|
||||
if normalized in ci:
|
||||
validates = any(
|
||||
token in lowered_text
|
||||
for token in ("pytest", "vitest", "jest", "npm test", "pnpm test", "go test", "dotnet test", "ruff", "eslint", "typecheck", "py_compile")
|
||||
)
|
||||
builds = any(
|
||||
token in lowered_text
|
||||
for token in ("npm run build", "pnpm build", "docker build", "vite build", "cargo build", "go build", "dotnet build")
|
||||
)
|
||||
deploys = any(token in lowered_text for token in ("deploy", "release", "publish", "kubectl apply"))
|
||||
if validates:
|
||||
ci_validation.append(normalized)
|
||||
if builds:
|
||||
ci_build.append(normalized)
|
||||
if deploys and not validates:
|
||||
ci_deploy_only.append(normalized)
|
||||
if any(token in lowered_text for token in ("--cov", "coverage", "lcov", "codecov")):
|
||||
coverage_evidence.append(normalized)
|
||||
if name.casefold() in COVERAGE_NAMES_CASEFOLD:
|
||||
coverage_evidence.append(normalized)
|
||||
if name.casefold() == "package.json":
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
deps: dict[object, object] = {}
|
||||
for section_name in (
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
"optionalDependencies",
|
||||
):
|
||||
section = data.get(section_name, {})
|
||||
if isinstance(section, dict):
|
||||
deps.update(section)
|
||||
if "vitest" in deps:
|
||||
frameworks.add("Vitest")
|
||||
if "jest" in deps:
|
||||
frameworks.add("Jest")
|
||||
if "@playwright/test" in deps:
|
||||
frameworks.add("Playwright")
|
||||
if "cypress" in deps:
|
||||
frameworks.add("Cypress")
|
||||
elif name.casefold() == "pyproject.toml":
|
||||
lowered = text.lower()
|
||||
if "pytest" in lowered:
|
||||
frameworks.add("pytest")
|
||||
|
||||
ratio = round((test_files / source_files) * 100, 2) if source_files else None
|
||||
return TestSignals(
|
||||
frameworks=sorted(frameworks),
|
||||
coverage_artifacts=sorted(set(coverage))[:30],
|
||||
ci_workflows=sorted(set(ci))[:30],
|
||||
ci_validation_workflows=sorted(set(ci_validation))[:30],
|
||||
ci_build_workflows=sorted(set(ci_build))[:30],
|
||||
ci_deploy_only_workflows=sorted(set(ci_deploy_only))[:30],
|
||||
coverage_evidence=sorted(set(coverage_evidence))[:30],
|
||||
test_source_ratio=ratio,
|
||||
)
|
||||
|
||||
|
||||
def analyze_dependencies(
|
||||
root: Path,
|
||||
files_by_name: dict[str, list[Path]],
|
||||
) -> tuple[DependencySignals, list[Finding]]:
|
||||
findings: list[Finding] = []
|
||||
manifests: list[str] = []
|
||||
lockfiles: list[str] = []
|
||||
dependency_origins: dict[str, set[str]] = defaultdict(set)
|
||||
declared = 0
|
||||
unbounded = 0
|
||||
unpinned = 0
|
||||
|
||||
for lock_name in sorted(LOCKFILES):
|
||||
for path in files_by_name.get(lock_name, []):
|
||||
lockfiles.append(str(path.relative_to(root)).replace("\\", "/"))
|
||||
|
||||
for package_json in files_by_name.get("package.json", []):
|
||||
relative = str(package_json.relative_to(root)).replace("\\", "/")
|
||||
manifests.append(relative)
|
||||
try:
|
||||
if package_json.stat().st_size > MANIFEST_MAX_BYTES:
|
||||
raise ValueError("package.json is too large to parse")
|
||||
data = json.loads(package_json.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("package.json root must be an object")
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError):
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.invalid-package-json",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="package.json could not be parsed safely",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
continue
|
||||
deps: dict[str, object] = {}
|
||||
invalid_sections = False
|
||||
for section_name in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
||||
section = data.get(section_name, {})
|
||||
if section is None:
|
||||
continue
|
||||
if not isinstance(section, dict):
|
||||
invalid_sections = True
|
||||
continue
|
||||
deps.update(section)
|
||||
if invalid_sections:
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.invalid-package-json",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="One or more package dependency sections are not objects",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
declared += len(deps)
|
||||
for name, version in deps.items():
|
||||
dependency_name = str(name)
|
||||
dependency_origins[dependency_name.casefold()].add(relative)
|
||||
raw = str(version).strip().lower()
|
||||
if raw in {"*", "latest", "x"} and not raw.startswith("workspace:"):
|
||||
unbounded += 1
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.unbounded",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message=f"Dependency {dependency_name} uses an unbounded version",
|
||||
path=relative,
|
||||
deduction=5,
|
||||
)
|
||||
)
|
||||
parent = package_json.parent
|
||||
package_roots = [parent, *[ancestor for ancestor in parent.parents if ancestor == root or root in ancestor.parents]]
|
||||
if not any(
|
||||
(package_root / lock).exists()
|
||||
for package_root in package_roots
|
||||
for lock in ("package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb")
|
||||
):
|
||||
findings.append(Finding(code="dependency.no-lockfile", category="Dependencies", severity=Severity.WARNING, message="JavaScript package has no recognized lockfile", path=relative, deduction=8))
|
||||
|
||||
requirement_paths = [
|
||||
path
|
||||
for name, values in files_by_name.items()
|
||||
if name.casefold().startswith("requirements") and name.casefold().endswith(".txt")
|
||||
for path in values
|
||||
]
|
||||
included_requirements: list[str] = []
|
||||
processed_requirements: set[Path] = set()
|
||||
pending_requirements = list(requirement_paths)
|
||||
while pending_requirements:
|
||||
requirements = pending_requirements.pop(0)
|
||||
try:
|
||||
requirements = requirements.resolve(strict=True)
|
||||
except OSError:
|
||||
continue
|
||||
if requirements in processed_requirements or requirements != root and root not in requirements.parents:
|
||||
continue
|
||||
processed_requirements.add(requirements)
|
||||
relative = str(requirements.relative_to(root)).replace("\\", "/")
|
||||
manifests.append(relative)
|
||||
try:
|
||||
lines = requirements.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.unreadable-requirements",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="Python requirements file could not be read",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
continue
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if stripped.startswith(("-r ", "--requirement ")):
|
||||
include = stripped.split(maxsplit=1)[1].strip()
|
||||
candidate = (requirements.parent / include).resolve()
|
||||
if candidate == root or root in candidate.parents:
|
||||
pending_requirements.append(candidate)
|
||||
included_requirements.append(str(candidate.relative_to(root)).replace("\\", "/"))
|
||||
continue
|
||||
if stripped.startswith("-"):
|
||||
continue
|
||||
declared += 1
|
||||
name = _python_dependency_name(stripped)
|
||||
if name:
|
||||
dependency_origins[name.casefold()].add(relative)
|
||||
finding = _python_dependency_finding(stripped, relative)
|
||||
if finding:
|
||||
unbounded += 1
|
||||
findings.append(finding)
|
||||
|
||||
for pyproject in files_by_name.get("pyproject.toml", []):
|
||||
relative = str(pyproject.relative_to(root)).replace("\\", "/")
|
||||
manifests.append(relative)
|
||||
try:
|
||||
if pyproject.stat().st_size > MANIFEST_MAX_BYTES:
|
||||
raise ValueError("pyproject.toml is too large to parse")
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("pyproject.toml root must be a table")
|
||||
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, TypeError):
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.invalid-pyproject",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="pyproject.toml could not be parsed safely",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
continue
|
||||
project = data.get("project", {})
|
||||
if not isinstance(project, dict):
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.invalid-pyproject",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="pyproject.toml project section is not a table",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
project = {}
|
||||
project_deps = project.get("dependencies", [])
|
||||
if isinstance(project_deps, list):
|
||||
declared += len(project_deps)
|
||||
for dependency in project_deps:
|
||||
declaration = str(dependency)
|
||||
name = _python_dependency_name(declaration)
|
||||
if name:
|
||||
dependency_origins[name.casefold()].add(relative)
|
||||
finding = _python_dependency_finding(declaration, relative)
|
||||
if finding:
|
||||
unbounded += 1
|
||||
findings.append(finding)
|
||||
elif project_deps is not None:
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.invalid-pyproject",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="pyproject.toml project.dependencies is not a list",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
|
||||
optional_deps = project.get("optional-dependencies", {})
|
||||
if isinstance(optional_deps, dict):
|
||||
for dependencies in optional_deps.values():
|
||||
if not isinstance(dependencies, list):
|
||||
continue
|
||||
declared += len(dependencies)
|
||||
for dependency in dependencies:
|
||||
declaration = str(dependency)
|
||||
name = _python_dependency_name(declaration)
|
||||
if name:
|
||||
dependency_origins[name.casefold()].add(relative)
|
||||
finding = _python_dependency_finding(declaration, relative)
|
||||
if finding:
|
||||
unbounded += 1
|
||||
findings.append(finding)
|
||||
elif optional_deps is not None:
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.invalid-pyproject",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="pyproject.toml project.optional-dependencies is not a table",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
|
||||
for go_mod in files_by_name.get("go.mod", []):
|
||||
relative = str(go_mod.relative_to(root)).replace("\\", "/")
|
||||
manifests.append(relative)
|
||||
try:
|
||||
text = go_mod.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.unreadable-go-mod",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="go.mod could not be read",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
continue
|
||||
matches = list(re.finditer(r"(?m)^\s*([\w./-]+)\s+v\d[^\s]*", text))
|
||||
if "require" in text and not matches:
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.invalid-go-mod",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="go.mod contains no parseable versioned dependencies",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
for match in matches:
|
||||
declared += 1
|
||||
dependency_origins[match.group(1).casefold()].add(relative)
|
||||
|
||||
for cargo in files_by_name.get("Cargo.toml", []):
|
||||
relative = str(cargo.relative_to(root)).replace("\\", "/")
|
||||
manifests.append(relative)
|
||||
try:
|
||||
data = tomllib.loads(cargo.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.invalid-cargo-toml",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="Cargo.toml could not be parsed safely",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
continue
|
||||
cargo_deps = data.get("dependencies", {})
|
||||
if isinstance(cargo_deps, dict):
|
||||
declared += len(cargo_deps)
|
||||
for name in cargo_deps:
|
||||
dependency_origins[str(name).casefold()].add(relative)
|
||||
|
||||
for pom in files_by_name.get("pom.xml", []):
|
||||
relative = str(pom.relative_to(root)).replace("\\", "/")
|
||||
manifests.append(relative)
|
||||
try:
|
||||
xml_root = ET.parse(pom).getroot()
|
||||
except (OSError, UnicodeError, ET.ParseError):
|
||||
findings.append(
|
||||
Finding(
|
||||
code="dependency.invalid-pom",
|
||||
category="Dependencies",
|
||||
severity=Severity.WARNING,
|
||||
message="pom.xml could not be parsed safely",
|
||||
path=relative,
|
||||
deduction=8,
|
||||
)
|
||||
)
|
||||
continue
|
||||
dependencies = [element for element in xml_root.iter() if element.tag.split("}")[-1] == "dependency"]
|
||||
declared += len(dependencies)
|
||||
for dependency in dependencies:
|
||||
artifact = next((child.text for child in dependency if child.tag.split("}")[-1] == "artifactId"), None)
|
||||
if artifact:
|
||||
dependency_origins[artifact.casefold()].add(relative)
|
||||
|
||||
duplicates = sorted(name for name, origins in dependency_origins.items() if len(origins) > 1)
|
||||
for name in duplicates[:25]:
|
||||
origins = sorted(dependency_origins[name])
|
||||
findings.append(Finding(code="dependency.duplicate-manifest", category="Dependencies", severity=Severity.INFO, message=f"Dependency {name} is declared across multiple manifests", path=origins[0], deduction=1))
|
||||
|
||||
return (
|
||||
DependencySignals(
|
||||
manifests=sorted(set(manifests)),
|
||||
lockfiles=sorted(set(lockfiles)),
|
||||
declared_dependencies=declared,
|
||||
unbounded_dependencies=unbounded,
|
||||
unpinned_dependencies=unpinned,
|
||||
duplicate_dependencies=duplicates[:50],
|
||||
included_requirements=sorted(set(included_requirements))[:50],
|
||||
),
|
||||
findings,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
[project]
|
||||
name = "repopulse-backend"
|
||||
version = "1.5.0"
|
||||
description = "RepoPulse repository intelligence API and deterministic analysis engine"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.116,<1",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
"pydantic>=2.11,<3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.4,<9",
|
||||
"ruff>=0.12,<1",
|
||||
"httpx>=0.28,<1",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
@@ -0,0 +1,122 @@
|
||||
from pathlib import Path
|
||||
from threading import BoundedSemaphore
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main as main_module
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def _repository(root: Path) -> Path:
|
||||
repository = root / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / "LICENSE").write_text("MIT\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text(".env\n", encoding="utf-8")
|
||||
(repository / "app.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
return repository
|
||||
|
||||
|
||||
def test_scan_api_respects_root_and_exports_reports(tmp_path: Path) -> None:
|
||||
repository = _repository(tmp_path)
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
first_response = client.post("/api/v1/scans", json={"path": "repo"})
|
||||
assert first_response.status_code == 201
|
||||
first = first_response.json()
|
||||
assert first["repository_path"] == str(repository.resolve())
|
||||
|
||||
html = client.get(f"/api/v1/scans/{first['id']}/export?format=html")
|
||||
assert html.status_code == 200
|
||||
assert "RepoPulse report" in html.text
|
||||
assert "repo" in html.text
|
||||
|
||||
blocked = client.post("/api/v1/scans", json={"path": str(tmp_path.parent)})
|
||||
assert blocked.status_code == 422
|
||||
|
||||
|
||||
def test_scan_api_comparison_delta_and_trend(tmp_path: Path) -> None:
|
||||
repository = _repository(tmp_path)
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
first = client.post("/api/v1/scans", json={"path": "repo"}).json()
|
||||
(repository / "tests").mkdir()
|
||||
(repository / "tests" / "test_app.py").write_text("def test_ok():\n assert True\n", encoding="utf-8")
|
||||
second = client.post("/api/v1/scans", json={"path": "repo"}).json()
|
||||
|
||||
comparison = client.get(f"/api/v1/scans/{second['id']}/comparison")
|
||||
assert comparison.status_code == 200
|
||||
assert comparison.json()["previous_scan_id"] == first["id"]
|
||||
|
||||
delta = client.get(f"/api/v1/scans/{second['id']}/delta")
|
||||
assert delta.status_code == 200
|
||||
assert delta.json()["previous_health_score"] == first["health_score"]
|
||||
assert delta.json()["previous_finding_count"] == len(first["findings"])
|
||||
|
||||
trend = client.get(f"/api/v1/trend?path={repository}")
|
||||
assert trend.status_code == 200
|
||||
assert [item["scan_id"] for item in trend.json()] == [first["id"], second["id"]]
|
||||
|
||||
|
||||
def test_scan_api_rejects_when_scan_capacity_is_busy(tmp_path: Path) -> None:
|
||||
_repository(tmp_path)
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
assert main_module.SCAN_SLOTS.acquire(blocking=False)
|
||||
try:
|
||||
response = client.post("/api/v1/scans", json={"path": "repo"})
|
||||
finally:
|
||||
main_module.SCAN_SLOTS.release()
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.headers["retry-after"] == "2"
|
||||
|
||||
|
||||
def test_scan_api_rejects_symlinked_repository_outside_root(tmp_path: Path) -> None:
|
||||
outside = tmp_path.parent / f"{tmp_path.name}-outside-repository"
|
||||
outside.mkdir()
|
||||
(outside / "app.py").write_text("print('outside')\n", encoding="utf-8")
|
||||
linked = tmp_path / "linked-repository"
|
||||
try:
|
||||
linked.symlink_to(outside, target_is_directory=True)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("directory symlinks are unavailable in this environment")
|
||||
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
response = client.post("/api/v1/scans", json={"path": "linked-repository"})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_mutating_api_requires_operator_token_only_when_configured(tmp_path: Path, monkeypatch) -> None:
|
||||
_repository(tmp_path)
|
||||
main_module.SCAN_ROOT = str(tmp_path)
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.SCAN_SLOTS = BoundedSemaphore(1)
|
||||
monkeypatch.setenv("REPOPULSE_OPERATOR_TOKEN", "test-operator-token")
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
assert client.get("/api/v1/meta").status_code == 200
|
||||
denied = client.post("/api/v1/scans", json={"path": "repo"})
|
||||
assert denied.status_code == 401
|
||||
|
||||
allowed = client.post(
|
||||
"/api/v1/scans",
|
||||
json={"path": "repo"},
|
||||
headers={"X-RepoPulse-Token": "test-operator-token"},
|
||||
)
|
||||
assert allowed.status_code == 201
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main as main_module
|
||||
from app.gitea import GiteaClient
|
||||
from app.models import Repository
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def test_repository_catalog_and_durable_scan_job_contract(tmp_path) -> None:
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.job_runner = None
|
||||
main_module.gitea_client = GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (500, b""))
|
||||
repository = Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="RepoPulse",
|
||||
full_name="Jens/RepoPulse",
|
||||
default_branch="main",
|
||||
latest_revision="0123456789abcdef0123456789abcdef01234567",
|
||||
)
|
||||
main_module.store.upsert_repository(repository)
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
catalog = client.get("/api/v1/repositories")
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()[0]["id"] == "gitea:64"
|
||||
assert "latest_scan" not in catalog.json()[0]
|
||||
|
||||
queued = client.post("/api/v1/repositories/gitea%3A64/scans", json={"force": False})
|
||||
assert queued.status_code == 202
|
||||
job = queued.json()
|
||||
assert job["repository_id"] == "gitea:64"
|
||||
assert job["status"] == "queued"
|
||||
|
||||
duplicate = client.post("/api/v1/repositories/gitea%3A64/scans", json={"force": False})
|
||||
assert duplicate.status_code == 202
|
||||
assert duplicate.json()["id"] == job["id"]
|
||||
assert duplicate.json()["deduplicated"] is True
|
||||
|
||||
detail = client.get("/api/v1/repositories/gitea%3A64")
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["latest_scan"] is None
|
||||
assert client.get(f"/api/v1/scan-jobs/{job['id']}").json()["status"] == "queued"
|
||||
|
||||
|
||||
def test_signed_gitea_webhook_only_schedules_default_branch_push(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.job_runner = None
|
||||
main_module.gitea_client = GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (500, b""))
|
||||
repository = Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="RepoPulse",
|
||||
full_name="Jens/RepoPulse",
|
||||
default_branch="main",
|
||||
latest_revision="0123456789abcdef0123456789abcdef01234567",
|
||||
)
|
||||
main_module.store.upsert_repository(repository)
|
||||
monkeypatch.setenv("REPOPULSE_GITEA_WEBHOOK_SECRET", "webhook-secret")
|
||||
payload = {
|
||||
"repository": {"id": 64},
|
||||
"ref": "refs/heads/main",
|
||||
"after": "abcdef0123456789abcdef0123456789abcdef01",
|
||||
}
|
||||
body = json.dumps(payload).encode()
|
||||
signature = hmac.new(b"webhook-secret", body, hashlib.sha256).hexdigest()
|
||||
client = TestClient(main_module.app)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/webhooks/gitea",
|
||||
content=body,
|
||||
headers={"X-Gitea-Signature": signature, "X-Gitea-Event": "push"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["job_id"]
|
||||
assert main_module.store.get_repository("gitea:64").latest_revision == payload["after"]
|
||||
|
||||
wrong_signature = client.post(
|
||||
"/api/v1/webhooks/gitea",
|
||||
content=body,
|
||||
headers={"X-Gitea-Signature": "0" * 64, "X-Gitea-Event": "push"},
|
||||
)
|
||||
assert wrong_signature.status_code == 401
|
||||
|
||||
feature_payload = payload | {"ref": "refs/heads/feature"}
|
||||
feature_body = json.dumps(feature_payload).encode()
|
||||
feature_signature = hmac.new(b"webhook-secret", feature_body, hashlib.sha256).hexdigest()
|
||||
feature = client.post(
|
||||
"/api/v1/webhooks/gitea",
|
||||
content=feature_body,
|
||||
headers={"X-Gitea-Signature": feature_signature, "X-Gitea-Event": "push"},
|
||||
)
|
||||
assert feature.status_code == 200
|
||||
assert feature.json()["job_id"] is None
|
||||
|
||||
|
||||
def test_meta_exposes_operational_counts_without_connector_token(tmp_path) -> None:
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.job_runner = None
|
||||
main_module.gitea_client = GiteaClient("http://gitea.local", "never-return-this-token")
|
||||
main_module.store.upsert_repository(
|
||||
Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="RepoPulse",
|
||||
full_name="Jens/RepoPulse",
|
||||
latest_revision="0123456789abcdef0123456789abcdef01234567",
|
||||
)
|
||||
)
|
||||
|
||||
response = TestClient(main_module.app).get("/api/v1/meta")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["repository_count"] == 1
|
||||
assert response.json()["repository_states"]["never_scanned"] == 1
|
||||
assert "never-return-this-token" not in response.text
|
||||
@@ -0,0 +1,98 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.architecture import build_architecture
|
||||
|
||||
|
||||
def test_architecture_resolves_python_and_relative_typescript_imports(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "web").mkdir()
|
||||
(tmp_path / "src" / "core.py").write_text("VALUE = 1\n", encoding="utf-8")
|
||||
(tmp_path / "src" / "app.py").write_text("import src.core\n", encoding="utf-8")
|
||||
(tmp_path / "web" / "util.ts").write_text("export const value = 1\n", encoding="utf-8")
|
||||
(tmp_path / "web" / "app.ts").write_text("import { value } from './util'\n", encoding="utf-8")
|
||||
(tmp_path / "web" / "side-effect.ts").write_text("import './util.js'\n", encoding="utf-8")
|
||||
|
||||
source_texts = {
|
||||
"src/core.py": "VALUE = 1\n",
|
||||
"src/app.py": "import src.core\n",
|
||||
"web/util.ts": "export const value = 1\n",
|
||||
"web/app.ts": "import { value } from './util'\n",
|
||||
"web/side-effect.ts": "import './util.js'\n",
|
||||
}
|
||||
languages = {
|
||||
"src/core.py": "Python",
|
||||
"src/app.py": "Python",
|
||||
"web/util.ts": "TypeScript",
|
||||
"web/app.ts": "TypeScript",
|
||||
"web/side-effect.ts": "TypeScript",
|
||||
}
|
||||
loc = {path: 1 for path in source_texts}
|
||||
|
||||
graph, hotspots = build_architecture(
|
||||
tmp_path,
|
||||
source_texts,
|
||||
languages,
|
||||
loc,
|
||||
{path: 0 for path in source_texts},
|
||||
{"src/core.py": 100, "src/app.py": 200},
|
||||
)
|
||||
|
||||
pairs = {(edge.source, edge.target) for edge in graph.edges}
|
||||
assert ("src/app.py", "src/core.py") in pairs
|
||||
assert ("web/app.ts", "web/util.ts") in pairs
|
||||
assert ("web/side-effect.ts", "web/util.ts") in pairs
|
||||
assert {node.id for node in graph.nodes} == set(source_texts)
|
||||
assert hotspots[0].path in {"src/app.py", "src/core.py"}
|
||||
|
||||
|
||||
def test_architecture_edge_bound_is_explicit(tmp_path: Path) -> None:
|
||||
source_texts = {
|
||||
"src/a.py": "import src.b\n",
|
||||
"src/b.py": "import src.c\n",
|
||||
"src/c.py": "VALUE = 1\n",
|
||||
}
|
||||
languages = {path: "Python" for path in source_texts}
|
||||
|
||||
graph, _ = build_architecture(
|
||||
tmp_path,
|
||||
source_texts,
|
||||
languages,
|
||||
{path: 1 for path in source_texts},
|
||||
{path: 0 for path in source_texts},
|
||||
{},
|
||||
max_edges=1,
|
||||
)
|
||||
|
||||
assert len(graph.edges) == 1
|
||||
assert graph.truncated is True
|
||||
|
||||
|
||||
def test_architecture_resolves_jsconfig_aliases_and_local_workspace_packages(tmp_path: Path) -> None:
|
||||
(tmp_path / "src" / "components").mkdir(parents=True)
|
||||
(tmp_path / "packages" / "shared" / "src").mkdir(parents=True)
|
||||
(tmp_path / "jsconfig.json").write_text(
|
||||
'{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "package.json").write_text('{"name":"workspace-root"}', encoding="utf-8")
|
||||
(tmp_path / "packages" / "shared" / "package.json").write_text(
|
||||
'{"name":"@workspace/shared"}', encoding="utf-8"
|
||||
)
|
||||
|
||||
source_texts = {
|
||||
"src/app.ts": "import { Button } from '@/components/Button'\nimport { shared } from '@workspace/shared'\n",
|
||||
"src/components/Button.ts": "export const Button = () => null\n",
|
||||
"packages/shared/src/index.ts": "export const shared = true\n",
|
||||
}
|
||||
graph, _ = build_architecture(
|
||||
tmp_path,
|
||||
source_texts,
|
||||
{path: "TypeScript" for path in source_texts},
|
||||
{path: 1 for path in source_texts},
|
||||
{path: 0 for path in source_texts},
|
||||
{},
|
||||
)
|
||||
|
||||
pairs = {(edge.source, edge.target) for edge in graph.edges}
|
||||
assert ("src/app.ts", "src/components/Button.ts") in pairs
|
||||
assert ("src/app.ts", "packages/shared/src/index.ts") in pairs
|
||||
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gitea import GiteaClient, GiteaError
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def _transport(url: str, headers: dict[str, str]) -> tuple[int, bytes]:
|
||||
assert headers["Authorization"] == "token test-token"
|
||||
if "/user/repos" in url:
|
||||
return 200, json.dumps(
|
||||
[
|
||||
{
|
||||
"id": 64,
|
||||
"name": "RepoPulse",
|
||||
"full_name": "Jens/RepoPulse",
|
||||
"owner": {"login": "Jens"},
|
||||
"description": "Repository intelligence",
|
||||
"default_branch": "main",
|
||||
"clone_url": "https://evil.example/Jens/RepoPulse.git",
|
||||
"private": True,
|
||||
}
|
||||
]
|
||||
).encode()
|
||||
if "/branches/main" in url:
|
||||
return 200, b'{"commit":{"id":"0123456789abcdef0123456789abcdef01234567"}}'
|
||||
raise AssertionError(url)
|
||||
|
||||
|
||||
def test_gitea_discovery_is_bounded_and_preserves_stable_identity() -> None:
|
||||
client = GiteaClient(
|
||||
"http://gitea.local",
|
||||
"test-token",
|
||||
transport=_transport,
|
||||
)
|
||||
|
||||
discovered = client.discover()
|
||||
|
||||
assert len(discovered) == 1
|
||||
repository, revision = discovered[0]
|
||||
assert repository.id == "gitea:64"
|
||||
assert repository.provider_repository_id == "64"
|
||||
assert repository.full_name == "Jens/RepoPulse"
|
||||
assert repository.clone_url == "http://gitea.local/Jens/RepoPulse.git"
|
||||
assert repository.latest_revision == revision
|
||||
|
||||
|
||||
def test_gitea_auth_errors_are_sanitized() -> None:
|
||||
def rejected(_url: str, _headers: dict[str, str]) -> tuple[int, bytes]:
|
||||
return 401, b"secret-bearing upstream response"
|
||||
|
||||
client = GiteaClient("http://gitea.local", "test-token", transport=rejected)
|
||||
|
||||
try:
|
||||
client.list_repositories()
|
||||
except GiteaError as exc:
|
||||
assert exc.code == "authentication"
|
||||
assert "test-token" not in str(exc)
|
||||
assert "secret-bearing" not in str(exc)
|
||||
else:
|
||||
raise AssertionError("authentication error was not raised")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code, error_code", [(401, "authentication"), (403, "authentication"), (404, "not_found"), (429, "rate_limited"), (500, "upstream")])
|
||||
def test_gitea_http_statuses_are_classified_without_upstream_body(status_code: int, error_code: str) -> None:
|
||||
client = GiteaClient(
|
||||
"http://gitea.local",
|
||||
"test-token",
|
||||
transport=lambda *_: (status_code, b"token=test-token upstream body"),
|
||||
)
|
||||
|
||||
with pytest.raises(GiteaError) as raised:
|
||||
client.list_repositories()
|
||||
|
||||
assert raised.value.code == error_code
|
||||
assert "test-token" not in str(raised.value)
|
||||
assert "upstream body" not in str(raised.value)
|
||||
|
||||
|
||||
def test_gitea_discovery_paginates_until_a_short_page() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def paginated(url: str, headers: dict[str, str]) -> tuple[int, bytes]:
|
||||
calls.append(url)
|
||||
assert headers["Authorization"] == "token test-token"
|
||||
if "/user/repos" in url:
|
||||
if "page=1" in url:
|
||||
payload = [{"id": 64, "owner": {"login": "Jens"}, "name": "One", "default_branch": "main"}]
|
||||
elif "page=2" in url:
|
||||
payload = [{"id": 65, "owner": {"login": "Jens"}, "name": "Two", "default_branch": "trunk"}]
|
||||
else:
|
||||
payload = []
|
||||
return 200, json.dumps(payload).encode()
|
||||
if "/branches/main" in url:
|
||||
return 200, b'{"commit":{"id":"0123456789abcdef0123456789abcdef01234567"}}'
|
||||
if "/branches/trunk" in url:
|
||||
return 200, b'{"commit":{"sha":"abcdef0123456789abcdef0123456789abcdef01"}}'
|
||||
raise AssertionError(url)
|
||||
|
||||
client = GiteaClient("http://gitea.local", "test-token", page_size=1, max_pages=3, transport=paginated)
|
||||
|
||||
discovered = client.discover()
|
||||
|
||||
assert [repository.id for repository, _ in discovered] == ["gitea:64", "gitea:65"]
|
||||
assert len([call for call in calls if "/user/repos" in call]) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"transport, error_code",
|
||||
[
|
||||
(lambda *_: (200, b"not-json"), "invalid_response"),
|
||||
(lambda *_: (_ for _ in ()).throw(TimeoutError()), "unreachable"),
|
||||
],
|
||||
)
|
||||
def test_gitea_malformed_and_timeout_responses_are_safe(transport, error_code: str) -> None:
|
||||
client = GiteaClient("http://gitea.local", "test-token", transport=transport)
|
||||
|
||||
with pytest.raises(GiteaError) as raised:
|
||||
client.list_repositories()
|
||||
|
||||
assert raised.value.code == error_code
|
||||
|
||||
|
||||
def test_gitea_sync_creates_catalog_without_full_reports(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
client = GiteaClient("http://gitea.local", "test-token", transport=_transport)
|
||||
|
||||
result = client.sync(store)
|
||||
|
||||
assert result.discovered == 1
|
||||
assert result.created == 1
|
||||
repository = store.get_repository("gitea:64")
|
||||
assert repository is not None
|
||||
assert repository.latest_revision == "0123456789abcdef0123456789abcdef01234567"
|
||||
assert repository.description == "Repository intelligence"
|
||||
assert store.repository_scans(repository.id) == []
|
||||
|
||||
|
||||
def test_gitea_outage_preserves_catalog_and_marks_sync_error(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
healthy = GiteaClient("http://gitea.local", "test-token", transport=_transport)
|
||||
healthy.sync(store)
|
||||
|
||||
unavailable = GiteaClient(
|
||||
"http://gitea.local",
|
||||
"test-token",
|
||||
transport=lambda *_: (503, b"upstream details must not escape"),
|
||||
)
|
||||
result = unavailable.sync(store)
|
||||
|
||||
assert result.errors == ["upstream: Gitea returned a server error"]
|
||||
repository = store.get_repository("gitea:64")
|
||||
assert repository is not None
|
||||
assert repository.available is True
|
||||
assert repository.latest_revision == "0123456789abcdef0123456789abcdef01234567"
|
||||
assert repository.sync_error == "upstream: Gitea returned a server error"
|
||||
|
||||
|
||||
def test_complete_sync_marks_missing_repository_unavailable_without_deleting_history(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
GiteaClient("http://gitea.local", "test-token", transport=_transport).sync(store)
|
||||
missing = GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (200, b"[]"))
|
||||
|
||||
result = missing.sync(store)
|
||||
|
||||
assert result.unavailable == 1
|
||||
repository = store.get_repository("gitea:64")
|
||||
assert repository is not None
|
||||
assert repository.available is False
|
||||
assert store.repository_scans(repository.id) == []
|
||||
|
||||
|
||||
def test_branch_resolution_failure_is_partial_and_preserves_other_catalog_entries(tmp_path: Path) -> None:
|
||||
def partial_transport(url: str, _headers: dict[str, str]) -> tuple[int, bytes]:
|
||||
if "/user/repos" in url:
|
||||
return 200, json.dumps(
|
||||
[
|
||||
{"id": 1, "owner": {"login": "Jens"}, "name": "Healthy", "default_branch": "main"},
|
||||
{"id": 2, "owner": {"login": "Jens"}, "name": "Broken", "default_branch": "main"},
|
||||
]
|
||||
).encode()
|
||||
if "/repos/Jens/Healthy/branches/main" in url:
|
||||
return 200, b'{"commit":{"id":"0123456789abcdef0123456789abcdef01234567"}}'
|
||||
if "/repos/Jens/Broken/branches/main" in url:
|
||||
return 500, b"upstream details must remain private"
|
||||
raise AssertionError(url)
|
||||
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
result = GiteaClient("http://gitea.local", "test-token", transport=partial_transport).sync(store)
|
||||
|
||||
assert result.discovered == 1
|
||||
assert result.partial is True
|
||||
assert result.branch_failures == 1
|
||||
assert result.listing_complete is True
|
||||
assert "Jens/Broken: upstream: Gitea returned a server error" in result.errors
|
||||
assert store.get_repository("gitea:1").latest_revision
|
||||
broken = store.get_repository("gitea:2")
|
||||
assert broken is not None and broken.available is True
|
||||
assert broken.sync_error == "Jens/Broken: upstream: Gitea returned a server error"
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.acquisition import AcquisitionError, GitAcquirer
|
||||
from app.gitea import GiteaClient
|
||||
from app.jobs import ScanJobRunner
|
||||
from app.models import Repository, ScanJobStatus, ScanSource, ScanTrigger, SourceOrigin
|
||||
from app.scanner import scan_repository
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def _git_repository(root: Path) -> tuple[Path, str]:
|
||||
root.mkdir()
|
||||
(root / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(root / "app.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "init", "--quiet", str(root)], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "config", "user.email", "test@example.com"], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "config", "user.name", "RepoPulse Test"], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "commit", "--quiet", "-m", "initial"], check=True)
|
||||
revision = subprocess.run(
|
||||
["git", "-C", str(root), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
return root, revision
|
||||
|
||||
|
||||
def _catalog_repository(source: Path, revision: str) -> Repository:
|
||||
return Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="Demo",
|
||||
full_name="Jens/Demo",
|
||||
clone_url=source.as_uri(),
|
||||
default_branch="master",
|
||||
latest_revision=revision,
|
||||
)
|
||||
|
||||
|
||||
def test_acquirer_scans_detached_exact_revision_and_cleans_up(tmp_path: Path) -> None:
|
||||
source, revision = _git_repository(tmp_path / "source")
|
||||
repository = _catalog_repository(source, revision)
|
||||
acquirer = GitAcquirer(tmp_path / "cache", timeout=30)
|
||||
|
||||
acquired = acquirer.acquire(repository, "job-1", revision)
|
||||
|
||||
checked_out = subprocess.run(
|
||||
["git", "-C", str(acquired.path), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert checked_out == revision
|
||||
assert (acquired.path / "app.py").read_text(encoding="utf-8") == "print('hello')\n"
|
||||
acquirer.cleanup(acquired, repository)
|
||||
assert not acquired.path.exists()
|
||||
|
||||
|
||||
def test_acquirer_recovers_a_corrupt_mirror_and_rejects_missing_sha(tmp_path: Path) -> None:
|
||||
source, revision = _git_repository(tmp_path / "source")
|
||||
repository = _catalog_repository(source, revision)
|
||||
acquirer = GitAcquirer(tmp_path / "cache", timeout=30)
|
||||
mirror = acquirer._mirror_path(repository)
|
||||
mirror.parent.mkdir(parents=True, exist_ok=True)
|
||||
mirror.write_text("corrupt mirror", encoding="utf-8")
|
||||
|
||||
with pytest.raises(AcquisitionError, match="Requested commit was not found"):
|
||||
acquirer.acquire(repository, "job-missing", "abcdef0123456789abcdef0123456789abcdef01")
|
||||
|
||||
acquired = acquirer.acquire(repository, "job-recovered", revision)
|
||||
assert acquired.actual_revision == revision
|
||||
acquirer.cleanup(acquired, repository)
|
||||
|
||||
|
||||
def test_durable_runner_completes_and_deduplicates_revision(tmp_path: Path) -> None:
|
||||
source, revision = _git_repository(tmp_path / "source")
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(source, revision)
|
||||
store.upsert_repository(repository)
|
||||
runner = ScanJobRunner(
|
||||
store,
|
||||
gitea=GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (500, b"")),
|
||||
acquirer=GitAcquirer(tmp_path / "cache", timeout=30),
|
||||
)
|
||||
|
||||
first = runner.enqueue(repository)
|
||||
completed = runner.process_one()
|
||||
reused = runner.enqueue(repository)
|
||||
|
||||
assert completed is not None
|
||||
assert completed.status == ScanJobStatus.COMPLETED
|
||||
assert completed.scan_id is not None
|
||||
assert reused.id == first.id
|
||||
assert reused.deduplicated is True
|
||||
report = store.get(completed.scan_id)
|
||||
assert report is not None
|
||||
assert report.source.actual_revision == revision
|
||||
assert report.source.trigger == ScanTrigger.MANUAL
|
||||
assert completed.duration_ms is not None
|
||||
|
||||
|
||||
def test_expired_job_lease_is_recovered_for_another_claim(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567")
|
||||
store.upsert_repository(repository)
|
||||
queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED)
|
||||
store.update_job(
|
||||
queued.id,
|
||||
status=ScanJobStatus.SCANNING,
|
||||
phase="scanning",
|
||||
lease_until=datetime.now(UTC) - timedelta(seconds=5),
|
||||
)
|
||||
|
||||
claimed = store.claim_next_job()
|
||||
|
||||
assert claimed is not None
|
||||
assert claimed.id == queued.id
|
||||
assert claimed.status == ScanJobStatus.ACQUIRING_SOURCE
|
||||
assert claimed.attempt == 1
|
||||
assert claimed.message == "Acquiring exact repository revision"
|
||||
|
||||
|
||||
def test_active_job_lease_renews_but_terminal_jobs_are_not_resurrected(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567")
|
||||
store.upsert_repository(repository)
|
||||
queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED)
|
||||
claimed = store.claim_next_job()
|
||||
|
||||
assert claimed is not None
|
||||
with store._connection() as db:
|
||||
initial_lease = db.execute("SELECT lease_until FROM scan_jobs WHERE id = ?", (queued.id,)).fetchone()[0]
|
||||
assert initial_lease is not None
|
||||
renewed = store.renew_job_lease(queued.id)
|
||||
assert renewed is not None
|
||||
with store._connection() as db:
|
||||
renewed_lease = db.execute("SELECT lease_until FROM scan_jobs WHERE id = ?", (queued.id,)).fetchone()[0]
|
||||
assert renewed_lease is not None and renewed_lease > initial_lease
|
||||
|
||||
completed = store.update_job(queued.id, status=ScanJobStatus.COMPLETED, phase="completed", progress=100)
|
||||
assert completed is not None
|
||||
after_completion = store.renew_job_lease(queued.id)
|
||||
assert after_completion is not None
|
||||
with store._connection() as db:
|
||||
terminal_lease = db.execute("SELECT lease_until FROM scan_jobs WHERE id = ?", (queued.id,)).fetchone()[0]
|
||||
assert terminal_lease is None
|
||||
|
||||
|
||||
def test_atomic_claim_allows_only_one_worker_to_own_a_queued_job(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567")
|
||||
store.upsert_repository(repository)
|
||||
store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as workers:
|
||||
claims = list(workers.map(lambda _unused: store.claim_next_job(), range(2)))
|
||||
|
||||
successful = [claim for claim in claims if claim is not None]
|
||||
assert len(successful) == 1
|
||||
assert successful[0].status == ScanJobStatus.ACQUIRING_SOURCE
|
||||
|
||||
|
||||
def test_scheduler_queues_unscanned_and_changed_repositories_only(tmp_path: Path) -> None:
|
||||
source, revision = _git_repository(tmp_path / "source")
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _catalog_repository(source, revision)
|
||||
store.upsert_repository(repository)
|
||||
runner = ScanJobRunner(store, gitea=GiteaClient())
|
||||
|
||||
runner._schedule_changed_repositories()
|
||||
first = store.claim_next_job()
|
||||
assert first is not None
|
||||
assert first.requested_revision == revision
|
||||
store.update_job(first.id, status=ScanJobStatus.SUPERSEDED)
|
||||
|
||||
report = scan_repository(
|
||||
str(source),
|
||||
source=ScanSource(
|
||||
origin=SourceOrigin.GITEA,
|
||||
provider="gitea",
|
||||
repository_id=repository.id,
|
||||
repository_full_name=repository.full_name,
|
||||
branch=repository.default_branch,
|
||||
requested_revision=revision,
|
||||
actual_revision=revision,
|
||||
trigger=ScanTrigger.MANUAL,
|
||||
),
|
||||
)
|
||||
store.save(report)
|
||||
runner._schedule_changed_repositories()
|
||||
assert store.queue_stats()["queued"] == 0
|
||||
|
||||
changed = "abcdef0123456789abcdef0123456789abcdef01"
|
||||
store.upsert_repository(repository.model_copy(update={"latest_revision": changed}))
|
||||
runner._schedule_changed_repositories()
|
||||
assert store.queue_stats()["queued"] == 1
|
||||
|
||||
disabled = repository.model_copy(
|
||||
update={
|
||||
"id": "gitea:65",
|
||||
"provider_repository_id": "65",
|
||||
"latest_revision": "1234567890abcdef1234567890abcdef12345678",
|
||||
"policy": repository.policy.model_copy(update={"auto_scan": False}),
|
||||
}
|
||||
)
|
||||
store.upsert_repository(disabled)
|
||||
runner._schedule_changed_repositories()
|
||||
assert store.queue_stats()["queued"] == 1
|
||||
|
||||
|
||||
def test_acquisition_rejects_token_bearing_clone_outside_gitea_origin(tmp_path: Path) -> None:
|
||||
repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567").model_copy(
|
||||
update={"clone_url": "https://evil.example/Jens/Demo.git"}
|
||||
)
|
||||
acquirer = GitAcquirer(
|
||||
tmp_path / "cache",
|
||||
token="test-token",
|
||||
allowed_clone_origin="https://gitea.local",
|
||||
)
|
||||
|
||||
with pytest.raises(AcquisitionError, match="outside the configured Gitea origin"):
|
||||
acquirer.acquire(repository, "job-1", repository.latest_revision)
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import main as main_module
|
||||
from app.gitea import GiteaClient
|
||||
from app.jobs import ScanJobRunner
|
||||
from app.models import Repository, ScanJobStatus, ScanTrigger
|
||||
from app.store import MaintenanceModeError, ScanStore
|
||||
|
||||
|
||||
def _gitea_repository() -> Repository:
|
||||
return Repository(
|
||||
id="gitea:64",
|
||||
provider="gitea",
|
||||
provider_repository_id="64",
|
||||
owner="Jens",
|
||||
name="RepoPulse",
|
||||
full_name="Jens/RepoPulse",
|
||||
default_branch="main",
|
||||
latest_revision="0123456789abcdef0123456789abcdef01234567",
|
||||
)
|
||||
|
||||
|
||||
def test_maintenance_mode_is_durable_authenticated_and_exposes_drain_state(tmp_path: Path, monkeypatch) -> None:
|
||||
database = tmp_path / "history.db"
|
||||
main_module.store = ScanStore(str(database))
|
||||
main_module.job_runner = None
|
||||
main_module.store.upsert_repository(_gitea_repository())
|
||||
queued = main_module.store.enqueue_job(
|
||||
"gitea:64",
|
||||
_gitea_repository().latest_revision,
|
||||
ScanTrigger.MANUAL,
|
||||
)
|
||||
monkeypatch.delenv("REPOPULSE_OPERATOR_TOKEN", raising=False)
|
||||
monkeypatch.delenv("REPOPULSE_MAINTENANCE_TOKEN", raising=False)
|
||||
client = TestClient(main_module.app)
|
||||
assert client.post("/api/v1/operator/maintenance/enter").status_code == 503
|
||||
monkeypatch.setenv("REPOPULSE_MAINTENANCE_TOKEN", "operator-secret")
|
||||
assert client.get("/api/v1/meta").json()["mutation_authentication"] == "perimeter_required"
|
||||
|
||||
assert client.post("/api/v1/operator/maintenance/enter").status_code == 401
|
||||
assert client.get("/api/v1/operator/maintenance").status_code == 401
|
||||
headers = {"X-RepoPulse-Token": "operator-secret"}
|
||||
entered = client.post("/api/v1/operator/maintenance/enter", headers=headers)
|
||||
|
||||
assert entered.status_code == 200
|
||||
assert entered.json()["enabled"] is True
|
||||
assert entered.json()["queue_depth"] == 1
|
||||
assert entered.json()["active_jobs"] == 0
|
||||
assert entered.json()["active_drained"] is True
|
||||
assert entered.json()["drained"] is False
|
||||
changed_at = entered.json()["changed_at"]
|
||||
assert client.post("/api/v1/operator/maintenance/enter", headers=headers).status_code == 423
|
||||
assert main_module.store.maintenance_status()["changed_at"] == changed_at
|
||||
assert client.get("/api/v1/operator/maintenance", headers=headers).json()["enabled"] is True
|
||||
assert client.get("/api/v1/meta").json()["maintenance"]["enabled"] is True
|
||||
|
||||
restarted = ScanStore(str(database))
|
||||
assert restarted.maintenance_status()["enabled"] is True
|
||||
assert restarted.get_job(queued.id).status == ScanJobStatus.QUEUED
|
||||
|
||||
assert client.post("/api/v1/operator/maintenance/exit").status_code == 401
|
||||
exited = client.post("/api/v1/operator/maintenance/exit", headers=headers)
|
||||
assert exited.status_code == 200
|
||||
assert exited.json()["enabled"] is False
|
||||
assert ScanStore(str(database)).maintenance_status()["enabled"] is False
|
||||
|
||||
|
||||
def test_paused_mode_rejects_api_mutations_but_keeps_read_endpoints_available(tmp_path: Path, monkeypatch) -> None:
|
||||
main_module.store = ScanStore(str(tmp_path / "history.db"))
|
||||
main_module.job_runner = None
|
||||
repository = _gitea_repository()
|
||||
main_module.store.upsert_repository(repository)
|
||||
queued = main_module.store.enqueue_job(
|
||||
repository.id,
|
||||
repository.latest_revision,
|
||||
ScanTrigger.MANUAL,
|
||||
)
|
||||
monkeypatch.delenv("REPOPULSE_OPERATOR_TOKEN", raising=False)
|
||||
monkeypatch.setenv("REPOPULSE_MAINTENANCE_TOKEN", "operator-secret")
|
||||
client = TestClient(main_module.app)
|
||||
headers = {"X-RepoPulse-Token": "operator-secret"}
|
||||
assert client.post("/api/v1/operator/maintenance/enter", headers=headers).status_code == 200
|
||||
|
||||
mutation_requests = [
|
||||
("/api/v1/scans", {}),
|
||||
("/api/v1/repositories/sync", {}),
|
||||
("/api/v1/repositories/gitea%3A64/scans", {"json": {"force": True}}),
|
||||
("/api/v1/webhooks/gitea", {"content": b"not validated while paused"}),
|
||||
("/api/v1/operator/database-recovery/acknowledge", {}),
|
||||
]
|
||||
for path, kwargs in mutation_requests:
|
||||
response = client.post(path, **kwargs)
|
||||
assert response.status_code == 423, (path, response.status_code, response.text)
|
||||
|
||||
assert client.get("/api/v1/repositories").status_code == 200
|
||||
assert client.get("/api/v1/scan-jobs/" + queued.id).status_code == 200
|
||||
assert main_module.store.queue_stats()["queued"] == 1
|
||||
assert main_module.store.get_job(queued.id).status == ScanJobStatus.QUEUED
|
||||
|
||||
|
||||
def test_paused_worker_makes_no_scheduler_writes_or_job_claims(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = _gitea_repository()
|
||||
store.upsert_repository(repository)
|
||||
queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED)
|
||||
store.set_state("worker_heartbeat", "before-maintenance")
|
||||
store.set_state("last_gitea_sync", "prior-sync")
|
||||
store.set_maintenance_mode(True)
|
||||
|
||||
class CountingGitea:
|
||||
configured = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sync_calls = 0
|
||||
|
||||
def sync(self, _store):
|
||||
self.sync_calls += 1
|
||||
raise AssertionError("scheduler must not sync in maintenance mode")
|
||||
|
||||
gitea = CountingGitea()
|
||||
runner = ScanJobRunner(store, gitea=gitea) # type: ignore[arg-type]
|
||||
|
||||
assert runner._run_iteration() is None
|
||||
assert gitea.sync_calls == 0
|
||||
assert store.get_state("worker_heartbeat") == "before-maintenance"
|
||||
assert store.get_state("last_gitea_sync") == "prior-sync"
|
||||
assert store.claim_next_job() is None
|
||||
assert store.queue_stats()["queued"] == 1
|
||||
assert store.get_job(queued.id).status == ScanJobStatus.QUEUED
|
||||
|
||||
|
||||
def test_already_claimed_job_can_finish_during_maintenance(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "README.md").write_text("# Existing work\n", encoding="utf-8")
|
||||
(source / "app.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
repository = Repository(
|
||||
id="local:existing-job",
|
||||
provider="local",
|
||||
provider_repository_id="existing-job",
|
||||
owner="local",
|
||||
name="Existing job",
|
||||
full_name="local/Existing job",
|
||||
default_branch="main",
|
||||
)
|
||||
store.upsert_repository(repository, source_path=str(source))
|
||||
store.enqueue_job(repository.id, None, ScanTrigger.MANUAL)
|
||||
claimed = store.claim_next_job()
|
||||
assert claimed is not None
|
||||
runner = ScanJobRunner(store, gitea=GiteaClient())
|
||||
|
||||
store.set_maintenance_mode(True)
|
||||
assert store.maintenance_status()["enabled"] is True
|
||||
assert store.queue_stats()["active"] == 1
|
||||
runner._process(claimed)
|
||||
|
||||
assert store.get_job(claimed.id).status == ScanJobStatus.COMPLETED
|
||||
assert store.queue_stats()["active"] == 0
|
||||
assert store.maintenance_status()["enabled"] is True
|
||||
|
||||
|
||||
def test_invalid_persisted_maintenance_state_fails_closed(tmp_path: Path) -> None:
|
||||
store = ScanStore(str(tmp_path / "history.db"))
|
||||
store.set_state("maintenance_mode", "not-json")
|
||||
|
||||
assert store.maintenance_status()["enabled"] is True
|
||||
with pytest.raises(MaintenanceModeError):
|
||||
store.enqueue_job("missing", "revision", ScanTrigger.MANUAL)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import (
|
||||
DependencySignals,
|
||||
RepositoryMetrics,
|
||||
ScanReport,
|
||||
)
|
||||
from app.models import (
|
||||
TestSignals as SignalsModel,
|
||||
)
|
||||
from app.scanner import scan_repository
|
||||
from app.store import SCHEMA_VERSION, ScanStore
|
||||
|
||||
|
||||
def test_v1_scan_schema_is_migrated_and_legacy_history_is_cataloged(tmp_path: Path) -> None:
|
||||
database = tmp_path / "history.db"
|
||||
report = ScanReport(
|
||||
id="legacy-scan",
|
||||
repository_path=str((tmp_path / "repo").resolve()),
|
||||
repository_name="repo",
|
||||
scanned_at="2026-09-12T00:00:00+00:00",
|
||||
duration_ms=1,
|
||||
metrics=RepositoryMetrics(),
|
||||
languages={},
|
||||
category_scores={},
|
||||
health_score=100,
|
||||
release_readiness="READY",
|
||||
findings=[],
|
||||
tests=SignalsModel(),
|
||||
dependencies=DependencySignals(),
|
||||
)
|
||||
connection = sqlite3.connect(database)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE scans (
|
||||
id TEXT PRIMARY KEY,
|
||||
repository_path TEXT NOT NULL,
|
||||
repository_name TEXT NOT NULL,
|
||||
scanned_at TEXT NOT NULL,
|
||||
health_score INTEGER NOT NULL,
|
||||
report_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO scans VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(report.id, report.repository_path, report.repository_name, report.scanned_at.isoformat(), 100, report.model_dump_json()),
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
store = ScanStore(str(database))
|
||||
|
||||
assert store.get_state("missing") is None
|
||||
assert store.repository_count() == 1
|
||||
migrated = store.get("legacy-scan")
|
||||
assert migrated is not None
|
||||
assert migrated.source.provider == "local"
|
||||
assert migrated.source.repository_id is not None
|
||||
assert store.list_repositories()[0].last_scan_id == "legacy-scan"
|
||||
connection = sqlite3.connect(database)
|
||||
assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_schema_upgrade_creates_a_timestamped_pre_migration_backup(tmp_path: Path) -> None:
|
||||
database = tmp_path / "history.db"
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
|
||||
original = ScanStore(str(database))
|
||||
saved = original.save(scan_repository(str(repository)))
|
||||
connection = sqlite3.connect(database)
|
||||
connection.execute("PRAGMA user_version = 3")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
migrated = ScanStore(str(database))
|
||||
|
||||
assert list(tmp_path.glob("history.db.pre-v3-*.bak"))
|
||||
assert migrated.get(saved.id) is not None
|
||||
@@ -0,0 +1,12 @@
|
||||
from app.reporting import content_disposition, safe_report_filename
|
||||
|
||||
|
||||
def test_report_filenames_are_safe_and_support_unicode() -> None:
|
||||
filename = safe_report_filename('repo/evil\r\n"<é>', "html")
|
||||
disposition = content_disposition(filename)
|
||||
|
||||
assert filename.startswith("repopulse-repo_evil___")
|
||||
assert "\r" not in disposition
|
||||
assert "\n" not in disposition
|
||||
assert 'filename="' in disposition
|
||||
assert "filename*=UTF-8''" in disposition
|
||||
@@ -0,0 +1,223 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import Severity
|
||||
from app.scanner import scan_repository
|
||||
|
||||
|
||||
def _write_good_repo(root: Path) -> None:
|
||||
(root / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(root / "LICENSE").write_text("MIT\n", encoding="utf-8")
|
||||
(root / ".gitignore").write_text(".env\n", encoding="utf-8")
|
||||
(root / "src").mkdir()
|
||||
(root / "tests").mkdir()
|
||||
(root / ".gitea" / "workflows").mkdir(parents=True)
|
||||
(root / ".gitea" / "workflows" / "check.yml").write_text("name: check\n", encoding="utf-8")
|
||||
(root / "src" / "core.py").write_text("def answer():\n return 42\n", encoding="utf-8")
|
||||
(root / "src" / "app.py").write_text("import src.core\n\ndef run():\n return src.core.answer()\n", encoding="utf-8")
|
||||
(root / "tests" / "test_app.py").write_text("def test_app():\n assert True\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _realistic_fixture_secret() -> str:
|
||||
parts = ["production", "secret", "value", "123456"]
|
||||
return "-".join(parts)
|
||||
|
||||
|
||||
def test_scan_collects_v1_signals_and_architecture(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.repository_name == tmp_path.name
|
||||
assert report.metrics.source_files == 3
|
||||
assert report.metrics.test_files == 1
|
||||
assert report.languages["Python"] > 0
|
||||
assert report.tests.ci_workflows == [".gitea/workflows/check.yml"]
|
||||
assert report.release_gate is not None
|
||||
assert report.release_gate.status in {"READY", "READY WITH WARNINGS"}
|
||||
assert any(edge.source == "src/app.py" and edge.target == "src/core.py" for edge in report.architecture.edges)
|
||||
assert any(item.path == "src/app.py" for item in report.hotspots)
|
||||
assert 0 <= report.health_score <= 100
|
||||
|
||||
|
||||
def test_scan_collects_debt_and_dependency_signals(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
(tmp_path / "package.json").write_text(
|
||||
'{"dependencies":{"react":"latest"},"devDependencies":{"vitest":"^3.0.0"}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "src" / "app.py").write_text(
|
||||
"# TODO improve this\n# FIXME remove workaround\nprint('hello')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.metrics.todo == 1
|
||||
assert report.metrics.fixme == 1
|
||||
assert report.dependencies.declared_dependencies == 2
|
||||
assert report.dependencies.unbounded_dependencies == 1
|
||||
assert any(item.code == "dependency.unbounded" for item in report.findings)
|
||||
|
||||
|
||||
def test_potential_secret_is_redacted_and_blocks_release(tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
secret_name = "API_" + "KEY"
|
||||
(tmp_path / "app.py").write_text(
|
||||
f"{secret_name} = 'super-secret-value-123456'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
||||
|
||||
assert report.release_readiness == "BLOCKED"
|
||||
assert report.release_gate is not None
|
||||
assert report.release_gate.status == "BLOCKED"
|
||||
assert secret_findings
|
||||
assert secret_findings[0].severity == Severity.BLOCKER
|
||||
assert "super-secret-example-value" not in secret_findings[0].message
|
||||
|
||||
|
||||
def test_secret_detection_scans_real_literals_in_tests_but_ignores_obvious_templates(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
realistic_secret = _realistic_fixture_secret()
|
||||
token_key = "to" + "ken"
|
||||
(tmp_path / "src" / "settings.py").write_text(
|
||||
"secret = os.getenv('APP_SECRET')\n" + f"api_key = '{realistic_secret}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "tests" / "test_transport.py").write_text(
|
||||
token_key + ' = "' + realistic_secret + '"\nresponse = b"token=test-token upstream body"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "README.md").write_text("# Demo\n\n`token=example-documentation-value`\n", encoding="utf-8")
|
||||
(tmp_path / ".env.development.example").write_text("APP_SECRET=example-development-secret\n", encoding="utf-8")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
||||
|
||||
assert len(secret_findings) == 2
|
||||
assert {item.path for item in secret_findings} == {"src/settings.py", "tests/test_transport.py"}
|
||||
assert all(item.guidance and item.guidance.remediation_class == "security remediation" for item in secret_findings)
|
||||
assert secret_findings[0].line == 2
|
||||
assert not any(item.code == "security.env-committed" for item in report.findings)
|
||||
|
||||
|
||||
def test_scan_budget_reports_coverage_without_turning_truncation_into_a_blocker(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
for index in range(4):
|
||||
(tmp_path / f"module_{index}.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
|
||||
from app.models import ScanLimits
|
||||
|
||||
report = scan_repository(str(tmp_path), limits=ScanLimits(max_files=2, max_text_bytes_per_file=2_000_000, max_findings=20, max_secret_findings=5))
|
||||
|
||||
assert report.coverage is not None and report.coverage.truncated is True
|
||||
assert report.coverage.skipped_files_by_budget > 0
|
||||
assert any(check.key == "analysis_coverage" and check.state.value == "warn" for check in report.release_gate.checks)
|
||||
assert any(item.code == "analysis.findings-truncated" for item in report.findings) is False
|
||||
|
||||
|
||||
def test_configuration_literal_secret_remains_a_blocker(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
realistic_secret = _realistic_fixture_secret()
|
||||
token_key = "to" + "ken"
|
||||
(tmp_path / "settings.json").write_text(
|
||||
json.dumps({token_key: realistic_secret}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
||||
|
||||
assert report.release_readiness == "BLOCKED"
|
||||
assert [(item.path, item.line) for item in secret_findings] == [("settings.json", 1)]
|
||||
|
||||
|
||||
def test_member_literal_secret_remains_a_blocker(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
(tmp_path / "src" / "client.py").write_text(
|
||||
"class Client:\n def __init__(self):\n self.api_key = 'production-secret-value-123456'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"]
|
||||
|
||||
assert report.release_readiness == "BLOCKED"
|
||||
assert [(item.path, item.line) for item in secret_findings] == [("src/client.py", 3)]
|
||||
|
||||
|
||||
def test_documentation_only_repository_does_not_require_test_files(tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Catalog entry\n", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text(".DS_Store\n", encoding="utf-8")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.metrics.source_files == 0
|
||||
assert not any(item.code == "testing.none-detected" for item in report.findings)
|
||||
|
||||
|
||||
def test_scan_skips_symlinked_files_outside_repository(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
external = tmp_path.parent / f"{tmp_path.name}-outside.py"
|
||||
external.write_text("API_KEY = 'outside-secret-example-value'\n", encoding="utf-8")
|
||||
linked = tmp_path / "linked.py"
|
||||
try:
|
||||
linked.symlink_to(external)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("symlinks are unavailable in this environment")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.metrics.source_files == 3
|
||||
assert all(finding.path != "linked.py" for finding in report.findings)
|
||||
|
||||
|
||||
def test_scan_skips_symlinked_directories_outside_repository(tmp_path: Path) -> None:
|
||||
_write_good_repo(tmp_path)
|
||||
external = tmp_path.parent / f"{tmp_path.name}-outside-dir"
|
||||
external.mkdir()
|
||||
(external / "outside.py").write_text("API_KEY = 'outside-secret-example-value'\n", encoding="utf-8")
|
||||
linked = tmp_path / "linked"
|
||||
try:
|
||||
linked.symlink_to(external, target_is_directory=True)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("directory symlinks are unavailable in this environment")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.metrics.source_files == 3
|
||||
assert all(finding.path != "linked/outside.py" for finding in report.findings)
|
||||
|
||||
|
||||
def test_malformed_dependency_manifests_are_reported_without_crashing(tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text("\n", encoding="utf-8")
|
||||
(tmp_path / "package.json").write_text("[]", encoding="utf-8")
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\ndependencies = \"invalid\"\n", encoding="utf-8")
|
||||
(tmp_path / "pom.xml").write_text("<project>", encoding="utf-8")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
codes = {finding.code for finding in report.findings}
|
||||
|
||||
assert "dependency.invalid-package-json" in codes
|
||||
assert "dependency.invalid-pyproject" in codes
|
||||
assert "dependency.invalid-pom" in codes
|
||||
|
||||
|
||||
def test_empty_git_repository_does_not_fallback_to_untracked_files(tmp_path: Path) -> None:
|
||||
(tmp_path / "stray.py").write_text("print('not tracked')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
|
||||
assert report.git.available is True
|
||||
assert report.git.tracked_files == 0
|
||||
assert report.metrics.files == 0
|
||||
assert report.metrics.source_files == 0
|
||||
@@ -0,0 +1,113 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.signals import analyze_dependencies, detect_test_signals
|
||||
|
||||
|
||||
def test_dependency_analysis_handles_multiple_ecosystems(tmp_path: Path) -> None:
|
||||
package = tmp_path / "package.json"
|
||||
package.write_text('{"dependencies":{"react":"latest"}}', encoding="utf-8")
|
||||
requirements = tmp_path / "requirements.txt"
|
||||
requirements.write_text("fastapi>=0.100\n", encoding="utf-8")
|
||||
lock = tmp_path / "package-lock.json"
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
|
||||
signals, findings = analyze_dependencies(
|
||||
tmp_path,
|
||||
{
|
||||
"package.json": [package],
|
||||
"requirements.txt": [requirements],
|
||||
"package-lock.json": [lock],
|
||||
},
|
||||
)
|
||||
|
||||
assert signals.declared_dependencies == 2
|
||||
assert signals.unbounded_dependencies == 2
|
||||
assert signals.unpinned_dependencies == 0
|
||||
assert "package-lock.json" in signals.lockfiles
|
||||
assert any(item.code == "dependency.unbounded" for item in findings)
|
||||
|
||||
|
||||
def test_python_dependency_ranges_only_flag_truly_unbounded_declarations(tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
"""
|
||||
[project]
|
||||
dependencies = [
|
||||
"exact==1.2.3",
|
||||
"compatible~=2.0",
|
||||
"bounded>=3.0,<4.0",
|
||||
"open>=5.0",
|
||||
]
|
||||
optional-dependencies.dev = ["pytest~=8.0"]
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
signals, findings = analyze_dependencies(tmp_path, {"pyproject.toml": [pyproject]})
|
||||
|
||||
assert signals.declared_dependencies == 5
|
||||
assert signals.unbounded_dependencies == 1
|
||||
assert [finding.code for finding in findings] == ["dependency.python-unbounded"]
|
||||
|
||||
|
||||
def test_test_signals_detect_ci_coverage_and_frameworks() -> None:
|
||||
text_by_path = {
|
||||
"package.json": '{"devDependencies":{"vitest":"^3.0.0","@playwright/test":"^1.0.0"}}',
|
||||
}
|
||||
signals = detect_test_signals(
|
||||
["src/app.ts", "src/app.test.ts", ".gitea/workflows/ci.yml", "coverage/lcov.info"],
|
||||
source_files=2,
|
||||
test_files=1,
|
||||
text_by_path=text_by_path,
|
||||
)
|
||||
|
||||
assert "Vitest" in signals.frameworks
|
||||
assert "Playwright" in signals.frameworks
|
||||
assert signals.ci_workflows == [".gitea/workflows/ci.yml"]
|
||||
assert signals.coverage_artifacts == ["coverage/lcov.info"]
|
||||
assert signals.test_source_ratio == 50.0
|
||||
|
||||
|
||||
def test_test_signals_distinguish_validation_from_deploy_only_workflows() -> None:
|
||||
signals = detect_test_signals(
|
||||
[".gitea/workflows/validate.yml", ".gitea/workflows/deploy.yml"],
|
||||
source_files=1,
|
||||
test_files=1,
|
||||
text_by_path={
|
||||
".gitea/workflows/validate.yml": "steps:\n - run: pytest --cov\n - run: npm run build\n",
|
||||
".gitea/workflows/deploy.yml": "steps:\n - run: deploy production\n",
|
||||
},
|
||||
)
|
||||
|
||||
assert signals.ci_validation_workflows == [".gitea/workflows/validate.yml"]
|
||||
assert signals.ci_build_workflows == [".gitea/workflows/validate.yml"]
|
||||
assert signals.ci_deploy_only_workflows == [".gitea/workflows/deploy.yml"]
|
||||
assert signals.coverage_evidence == [".gitea/workflows/validate.yml"]
|
||||
|
||||
|
||||
def test_dependency_analysis_follows_local_requirements_includes_and_workspace_locks(tmp_path: Path) -> None:
|
||||
packages = tmp_path / "packages" / "web"
|
||||
packages.mkdir(parents=True)
|
||||
package = packages / "package.json"
|
||||
package.write_text('{"dependencies":{"shared":"workspace:*"}}', encoding="utf-8")
|
||||
lock = tmp_path / "pnpm-lock.yaml"
|
||||
lock.write_text("lockfileVersion: '9.0'\n", encoding="utf-8")
|
||||
requirements = tmp_path / "requirements.txt"
|
||||
included = tmp_path / "requirements-prod.txt"
|
||||
requirements.write_text("-r requirements-prod.txt\n", encoding="utf-8")
|
||||
included.write_text("fastapi==0.116.0\n", encoding="utf-8")
|
||||
|
||||
signals, findings = analyze_dependencies(
|
||||
tmp_path,
|
||||
{
|
||||
"package.json": [package],
|
||||
"pnpm-lock.yaml": [lock],
|
||||
"requirements.txt": [requirements],
|
||||
"requirements-prod.txt": [included],
|
||||
},
|
||||
)
|
||||
|
||||
assert signals.unbounded_dependencies == 0
|
||||
assert signals.included_requirements == ["requirements-prod.txt"]
|
||||
assert signals.declared_dependencies == 2
|
||||
assert not any(item.code == "dependency.no-lockfile" for item in findings)
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.git_analysis import analyze_git
|
||||
from app.scanner import _walk_files, scan_repository
|
||||
|
||||
|
||||
def test_non_git_fallback_applies_root_gitignore_rules(tmp_path: Path) -> None:
|
||||
(tmp_path / ".gitignore").write_text("ignored.py\nignored-dir/\n*.log\n!keep.log\n", encoding="utf-8")
|
||||
(tmp_path / "kept.py").write_text("print('kept')\n", encoding="utf-8")
|
||||
(tmp_path / "ignored.py").write_text("print('ignored')\n", encoding="utf-8")
|
||||
(tmp_path / "keep.log").write_text("kept\n", encoding="utf-8")
|
||||
(tmp_path / "drop.log").write_text("dropped\n", encoding="utf-8")
|
||||
(tmp_path / "ignored-dir").mkdir()
|
||||
(tmp_path / "ignored-dir" / "nested.py").write_text("print('nested')\n", encoding="utf-8")
|
||||
|
||||
files = {path.relative_to(tmp_path).as_posix() for path in _walk_files(tmp_path)}
|
||||
|
||||
assert "kept.py" in files
|
||||
assert "keep.log" in files
|
||||
assert "ignored.py" not in files
|
||||
assert "drop.log" not in files
|
||||
assert "ignored-dir/nested.py" not in files
|
||||
|
||||
|
||||
def test_git_churn_attributes_renames_to_the_new_path(tmp_path: Path) -> None:
|
||||
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "test@example.com"], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "RepoPulse Test"], check=True)
|
||||
(tmp_path / "old.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(tmp_path), "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "initial"], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "mv", "old.py", "new.py"], check=True)
|
||||
subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "rename"], check=True)
|
||||
(tmp_path / "new.py").write_text("print('hello world')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-am", "edit after rename"], check=True)
|
||||
|
||||
signals, churn = analyze_git(tmp_path)
|
||||
|
||||
assert signals.renamed_files == ["new.py"]
|
||||
assert churn.get("new.py", 0) > 0
|
||||
assert "old.py" not in churn
|
||||
|
||||
|
||||
def test_policy_suppresses_accepted_risk_without_erasing_the_observation(tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(tmp_path / ".gitignore").write_text("\n", encoding="utf-8")
|
||||
(tmp_path / ".repopulse.yml").write_text("suppressions:\n - security.secret-assignment\n", encoding="utf-8")
|
||||
(tmp_path / "app.py").write_text("api_key = 'abcdefghijk1234'\n", encoding="utf-8")
|
||||
|
||||
report = scan_repository(str(tmp_path))
|
||||
finding = next(item for item in report.findings if item.code == "security.secret-assignment")
|
||||
|
||||
assert finding.suppressed is True
|
||||
assert report.category_scores["Security basics"] == 100
|
||||
assert report.release_gate is not None
|
||||
assert report.release_gate.checks[0].state.value == "pass"
|
||||
@@ -0,0 +1,161 @@
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import app.store as store_module
|
||||
from app.scanner import scan_repository
|
||||
from app.store import ScanStore
|
||||
|
||||
|
||||
def test_scan_history_comparison_and_trend(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
first = store.save(scan_repository(str(repository)))
|
||||
|
||||
(repository / "app.py").write_text("print('hello')\n", encoding="utf-8")
|
||||
(repository / "tests").mkdir()
|
||||
(repository / "tests" / "test_app.py").write_text("def test_ok():\n assert True\n", encoding="utf-8")
|
||||
second = store.save(scan_repository(str(repository)))
|
||||
|
||||
assert store.get(second.id) == second
|
||||
assert store.recent(1) == [second]
|
||||
assert store.previous_for_repository(second.repository_path, second.id) == first
|
||||
|
||||
comparison = store.compare(second)
|
||||
assert comparison.previous_scan_id == first.id
|
||||
assert comparison.health_score_delta == second.health_score - first.health_score
|
||||
assert comparison.metric_deltas["source_files"] == 2
|
||||
|
||||
trend = store.trend(second.repository_path)
|
||||
assert [item.scan_id for item in trend] == [first.id, second.id]
|
||||
assert trend[-1].health_score == second.health_score
|
||||
|
||||
|
||||
def test_comparison_uses_the_scan_before_the_selected_scan(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
first = store.save(scan_repository(str(repository)))
|
||||
second = store.save(scan_repository(str(repository)))
|
||||
third = store.save(scan_repository(str(repository)))
|
||||
|
||||
assert store.previous_for_repository(second.repository_path, second.id) == first
|
||||
assert store.previous_for_repository(third.repository_path, third.id) == second
|
||||
|
||||
|
||||
def test_repository_summary_exposes_compact_remediation_context(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text(".env\n", encoding="utf-8")
|
||||
(repository / ".env").write_text("TOKEN=actual-secret-value-123456\n", encoding="utf-8")
|
||||
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
store.save(scan_repository(str(repository)))
|
||||
|
||||
(repository / ".env").unlink()
|
||||
(repository / "app.py").write_text("api_key = 'actual-secret-value-123456'\n", encoding="utf-8")
|
||||
store.save(scan_repository(str(repository)))
|
||||
|
||||
summary = store.list_repositories()[0]
|
||||
|
||||
assert summary.last_scan_blockers == 1
|
||||
assert summary.last_scan_primary_signal is not None
|
||||
assert summary.last_scan_primary_signal.code == "security.secret-assignment"
|
||||
assert summary.last_scan_primary_signal.count == 1
|
||||
assert summary.last_scan_added_findings >= 1
|
||||
assert summary.last_scan_resolved_findings == 1
|
||||
|
||||
|
||||
def test_corrupt_database_is_quarantined_and_recreated(tmp_path: Path) -> None:
|
||||
database = tmp_path / "repopulse.db"
|
||||
database.write_bytes(b"not a sqlite database")
|
||||
|
||||
store = ScanStore(str(database))
|
||||
|
||||
assert store.recent() == []
|
||||
assert list(tmp_path.glob("repopulse.db.corrupt-*"))
|
||||
assert store.database_recovery_status()["status"] == "degraded_recovered"
|
||||
|
||||
|
||||
def test_schema_migration_records_the_durable_backup_artifact(tmp_path: Path) -> None:
|
||||
database = tmp_path / "repopulse.db"
|
||||
initial_store = ScanStore(str(database))
|
||||
with initial_store._connection() as db:
|
||||
db.execute("PRAGMA user_version = 3")
|
||||
|
||||
migrated_store = ScanStore(str(database))
|
||||
|
||||
recorded = migrated_store.get_state("last_schema_migration_backup")
|
||||
assert recorded is not None
|
||||
metadata = json.loads(recorded)
|
||||
assert metadata["from_version"] == 3
|
||||
assert metadata["to_version"] == store_module.SCHEMA_VERSION
|
||||
assert (tmp_path / metadata["backup_file"]).is_file()
|
||||
assert migrated_store.storage_stats()["last_schema_migration_backup"] == recorded
|
||||
|
||||
|
||||
def test_history_is_bounded_per_repository(tmp_path: Path, monkeypatch) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(store_module, "MAX_HISTORY_PER_REPOSITORY", 2)
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
first = store.save(scan_repository(str(repository)))
|
||||
second = store.save(scan_repository(str(repository)))
|
||||
third = store.save(scan_repository(str(repository)))
|
||||
|
||||
assert [item.id for item in store.recent_for_repository(third.repository_path)] == [third.id, second.id]
|
||||
assert store.get(first.id) is None
|
||||
|
||||
|
||||
def test_retention_compacts_old_detailed_payloads_and_expires_old_snapshots(tmp_path: Path, monkeypatch) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
monkeypatch.setenv("REPOPULSE_FULL_REPORTS_PER_REPOSITORY", "1")
|
||||
monkeypatch.setenv("REPOPULSE_REPORT_RETENTION_DAYS", "30")
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
|
||||
first = store.save(scan_repository(str(repository)))
|
||||
second = store.save(scan_repository(str(repository)))
|
||||
compacted = store.get(first.id)
|
||||
|
||||
assert compacted is not None and compacted.compacted is True
|
||||
assert compacted.findings == []
|
||||
assert store.get(second.id) is not None
|
||||
|
||||
expired = second.model_copy(
|
||||
update={"id": "expired-scan", "scanned_at": datetime.now(UTC) - timedelta(days=31)}
|
||||
)
|
||||
store.save(expired)
|
||||
assert store.get("expired-scan") is None
|
||||
|
||||
|
||||
def test_catalog_summary_uses_denormalized_fields_without_report_json_parsing(tmp_path: Path, monkeypatch) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
(repository / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(repository / ".gitignore").write_text("*.db\n", encoding="utf-8")
|
||||
store = ScanStore(str(tmp_path / "repopulse.db"))
|
||||
store.save(scan_repository(str(repository)))
|
||||
|
||||
def fail_if_called(_row):
|
||||
raise AssertionError("catalog list must not parse report_json")
|
||||
|
||||
monkeypatch.setattr(store, "_report_from_row", fail_if_called)
|
||||
summary = store.list_repositories()[0]
|
||||
assert summary.last_scan_id
|
||||
storage = store.storage_stats()
|
||||
assert storage["scan_count"] == 1
|
||||
assert storage["mirror_count"] >= 0
|
||||
assert storage["worktree_count"] >= 0
|
||||
@@ -0,0 +1,80 @@
|
||||
name: repopulse
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
container_name: repopulse-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REPOPULSE_SCAN_ROOT: /scan
|
||||
REPOPULSE_DB: /data/repopulse.db
|
||||
REPOPULSE_MAX_CONCURRENT_SCANS: "${REPOPULSE_MAX_CONCURRENT_SCANS:-1}"
|
||||
REPOPULSE_JOB_LEASE_SECONDS: "${REPOPULSE_JOB_LEASE_SECONDS:-900}"
|
||||
REPOPULSE_SCAN_MAX_FILES: "${REPOPULSE_SCAN_MAX_FILES:-10000}"
|
||||
REPOPULSE_SCAN_MAX_TEXT_BYTES: "${REPOPULSE_SCAN_MAX_TEXT_BYTES:-2000000}"
|
||||
REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES: "${REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES:-50000000}"
|
||||
REPOPULSE_SCAN_MAX_FINDINGS: "${REPOPULSE_SCAN_MAX_FINDINGS:-2000}"
|
||||
REPOPULSE_SCAN_MAX_SECRET_FINDINGS: "${REPOPULSE_SCAN_MAX_SECRET_FINDINGS:-20}"
|
||||
REPOPULSE_GITEA_URL: "${REPOPULSE_GITEA_URL:-}"
|
||||
REPOPULSE_GITEA_TOKEN: "${REPOPULSE_GITEA_TOKEN:-}"
|
||||
REPOPULSE_GITEA_BRANCH_WORKERS: "${REPOPULSE_GITEA_BRANCH_WORKERS:-4}"
|
||||
REPOPULSE_GITEA_WEBHOOK_SECRET: "${REPOPULSE_GITEA_WEBHOOK_SECRET:-}"
|
||||
REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS: "${REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS:-300}"
|
||||
REPOPULSE_REPOSITORY_CACHE: /data/repos
|
||||
REPOPULSE_MAX_QUEUED_SCANS: "${REPOPULSE_MAX_QUEUED_SCANS:-100}"
|
||||
REPOPULSE_HISTORY_PER_REPOSITORY: "${REPOPULSE_HISTORY_PER_REPOSITORY:-200}"
|
||||
REPOPULSE_FULL_REPORTS_PER_REPOSITORY: "${REPOPULSE_FULL_REPORTS_PER_REPOSITORY:-30}"
|
||||
REPOPULSE_REPORT_RETENTION_DAYS: "${REPOPULSE_REPORT_RETENTION_DAYS:-365}"
|
||||
REPOPULSE_OPERATOR_TOKEN: "${REPOPULSE_OPERATOR_TOKEN:-}"
|
||||
volumes:
|
||||
- "${REPOPULSE_DATA_ROOT:-repopulse_data}:/data"
|
||||
- "${REPOPULSE_SOURCE_ROOT:-..}:/scan:ro"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 5
|
||||
labels:
|
||||
net.unraid.docker.managed: "dockerman"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
container_name: repopulse
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${REPOPULSE_PORT:-1254}:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 5
|
||||
labels:
|
||||
net.unraid.docker.managed: "dockerman"
|
||||
net.unraid.docker.webui: "http://127.0.0.1:${REPOPULSE_PORT:-1254}"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
repopulse_data:
|
||||
name: repopulse_data
|
||||
@@ -0,0 +1,57 @@
|
||||
name: repopulse
|
||||
|
||||
services:
|
||||
repopulse:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.unraid
|
||||
container_name: repopulse
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REPOPULSE_SCAN_ROOT: /scan
|
||||
REPOPULSE_DB: /data/repopulse.db
|
||||
REPOPULSE_STATIC_DIR: /app/static
|
||||
REPOPULSE_MAX_CONCURRENT_SCANS: "${REPOPULSE_MAX_CONCURRENT_SCANS:-1}"
|
||||
REPOPULSE_JOB_LEASE_SECONDS: "${REPOPULSE_JOB_LEASE_SECONDS:-900}"
|
||||
REPOPULSE_SCAN_MAX_FILES: "${REPOPULSE_SCAN_MAX_FILES:-10000}"
|
||||
REPOPULSE_SCAN_MAX_TEXT_BYTES: "${REPOPULSE_SCAN_MAX_TEXT_BYTES:-2000000}"
|
||||
REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES: "${REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES:-50000000}"
|
||||
REPOPULSE_SCAN_MAX_FINDINGS: "${REPOPULSE_SCAN_MAX_FINDINGS:-2000}"
|
||||
REPOPULSE_SCAN_MAX_SECRET_FINDINGS: "${REPOPULSE_SCAN_MAX_SECRET_FINDINGS:-20}"
|
||||
REPOPULSE_GITEA_URL: "${REPOPULSE_GITEA_URL:-}"
|
||||
REPOPULSE_GITEA_TOKEN: "${REPOPULSE_GITEA_TOKEN:-}"
|
||||
REPOPULSE_GITEA_BRANCH_WORKERS: "${REPOPULSE_GITEA_BRANCH_WORKERS:-4}"
|
||||
REPOPULSE_GITEA_WEBHOOK_SECRET: "${REPOPULSE_GITEA_WEBHOOK_SECRET:-}"
|
||||
REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS: "${REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS:-300}"
|
||||
REPOPULSE_REPOSITORY_CACHE: /data/repos
|
||||
REPOPULSE_MAX_QUEUED_SCANS: "${REPOPULSE_MAX_QUEUED_SCANS:-100}"
|
||||
REPOPULSE_HISTORY_PER_REPOSITORY: "${REPOPULSE_HISTORY_PER_REPOSITORY:-200}"
|
||||
REPOPULSE_FULL_REPORTS_PER_REPOSITORY: "${REPOPULSE_FULL_REPORTS_PER_REPOSITORY:-30}"
|
||||
REPOPULSE_REPORT_RETENTION_DAYS: "${REPOPULSE_REPORT_RETENTION_DAYS:-365}"
|
||||
REPOPULSE_OPERATOR_TOKEN: "${REPOPULSE_OPERATOR_TOKEN:-}"
|
||||
REPOPULSE_MAINTENANCE_TOKEN: "${REPOPULSE_MAINTENANCE_TOKEN:-}"
|
||||
volumes:
|
||||
- "${REPOPULSE_DATA_ROOT:-repopulse_data}:/data"
|
||||
- "${REPOPULSE_SOURCE_ROOT:-..}:/scan:ro"
|
||||
ports:
|
||||
- "${REPOPULSE_PORT:-1254}:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 5
|
||||
labels:
|
||||
net.unraid.docker.managed: "dockerman"
|
||||
net.unraid.docker.webui: "http://127.0.0.1:${REPOPULSE_PORT:-1254}"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
repopulse_data:
|
||||
name: repopulse_data
|
||||
@@ -0,0 +1,100 @@
|
||||
# RepoPulse Architecture
|
||||
|
||||
## Runtime shape
|
||||
|
||||
RepoPulse is a Gitea-native deterministic analysis service. Source code is read by the backend only; the browser receives repository summaries, derived metrics, findings and bounded graph metadata. Gitea is a read-only provider, while SQLite remains the source of RepoPulse catalog and history state during provider outages.
|
||||
|
||||
```text
|
||||
Gitea (read-only API + Git)
|
||||
│ catalog + default-branch SHA
|
||||
▼
|
||||
FastAPI ── SQLite catalog / durable job queue / scan history (/data)
|
||||
│ │
|
||||
│ ├── bounded mirror cache (/data/repos/mirrors)
|
||||
│ └── detached worktrees (/data/repos/worktrees)
|
||||
│ │
|
||||
│ ▼
|
||||
├── single-process worker → deterministic scanner → report persistence
|
||||
│
|
||||
└── /api → Nginx / React repository cockpit
|
||||
│
|
||||
└── Advanced local provider → read-only /scan
|
||||
```
|
||||
|
||||
The worker starts and stops through FastAPI lifespan. SQLite claim transactions and leases make queued work restart-safe; the current deployment intentionally uses one worker and one active scan by default, without Redis or Celery.
|
||||
|
||||
## Repository identity and lifecycle
|
||||
|
||||
Gitea repositories are first-class records keyed by `id = gitea:<numeric-provider-id>` and constrained by `(provider, provider_repository_id)`. Owner/name, description, URLs, visibility, archive state, default branch and observed HEAD metadata are catalog attributes; filesystem paths are not primary identity. Local scans use a separate stable `local:<path-hash>` identity for v1 compatibility.
|
||||
|
||||
Catalog synchronization is bounded and paginated. After a successful listing it resolves each default-branch HEAD independently with a bounded `REPOPULSE_GITEA_BRANCH_WORKERS` pool (default 4). A branch failure is persisted as a sanitized repository-specific `sync_error`, while every other repository is upserted and remains usable. A failed or truncated listing preserves all prior catalog/history rows; only a complete listing may mark an absent repository unavailable. Archived/unavailable records are retained and are not cascade-deleted.
|
||||
|
||||
The computed repository state is one of `never_scanned`, `current`, `stale`, `queued`, `scanning`, `failed`, `unavailable` or `archived`. The scheduler reconciles `latest_revision` against `last_scan_revision`, respects `policy.auto_scan`, queues new/stale revisions, and retries after the next sync interval. It does not queue a repository whose observed HEAD already has a successful scan.
|
||||
|
||||
## Exact source acquisition
|
||||
|
||||
For a Gitea job, the requested SHA is captured at enqueue time. The acquirer uses the configured Gitea origin only, passes the token through a subprocess environment header rather than a URL, fetches the selected default branch into a provider-ID mirror, resolves the requested immutable commit, and creates a detached worktree under the managed cache. The scanner sees that one worktree only; cleanup removes it and prunes stale Git registrations. Corrupt mirrors are discarded only after a local bare-repository validation failure, so a remote outage does not erase a usable cache.
|
||||
|
||||
## Scan boundary
|
||||
|
||||
When `REPOPULSE_SCAN_ROOT` is configured, every local scan path is resolved and checked against that root before analysis. Compose sets the root to `/scan` and mounts the user-selected source directory read-only. The scanner then resolves every candidate file, rejects symlinked files and directories (including symlink ancestors), and requires the resolved file to remain below the scan root. This prevents the API from being used as a generic filesystem browser outside the configured analysis boundary. Gitea worktrees are acquired into `/data`, not `/scan`, and are controlled by repository-ID/job-ID validation.
|
||||
|
||||
## Persistence and deployment boundary
|
||||
|
||||
The exact-source deployment controller replaces the registered repository source directory on each deployment. SQLite history, the catalog, queue and mirror cache must therefore not live below that directory. Compose uses the Docker-managed named volume `repopulse_data` for `/data` when `REPOPULSE_DATA_ROOT` is unset (the production configuration); this volume is independent of source refreshes and container recreation. The checked-in `.env.example` opts local Compose into `REPOPULSE_DATA_ROOT=./data` for convenient checkout-local development. `/scan` remains the read-only source boundary and `/data` is the only writable application mount.
|
||||
|
||||
The AppOps-managed runtime candidate is defined separately in `.itworx/deployment.yml` and built with `deploy/managed/Dockerfile`; the legacy root `docker-compose.yml` keeps its existing topology and now forwards the optional `REPOPULSE_MAINTENANCE_TOKEN` so the old writer can be authenticated and quiesced before cutover. `Dockerfile.unraid` remains unchanged pending cutover. The managed target uses the immutable controller-selected source revision to build image `repopulse-managed:production`, runs as UID/GID `10001:10001` with a read-only root filesystem and one existing NPM network, binds `/mnt/pool/appdata/repopulse/data` to writable `/data`, and binds only the existing controller-owned `repopulse` and `privatemuse` source directories read-only at `/scan/repopulse` and `/scan/privatemuse`. The fixed AppOps profile allows only these exact external source mounts and rejects other paths. Git mirrors and worktrees stay under `/data/repos`, while process scratch files use the bounded `/tmp` tmpfs. Runtime secrets are restricted to the fixed Gitea URL/token and a dedicated RepoPulse maintenance token in `deploy.managed.env`; the legacy `deploy.env` remains available for rollback. No Gitea webhook secret is configured.
|
||||
|
||||
## Inventory pipeline and analysis coverage
|
||||
|
||||
The scanner prefers `git ls-files` for a top-level worktree so ignored/untracked build artifacts do not distort results. An empty Git worktree remains empty rather than silently falling back. Non-Git directories use a filesystem traversal that excludes common generated/vendor directories and applies bounded root/nested `.gitignore` rules. Text files larger than the configured internal read threshold are not parsed as source, and binary files are skipped.
|
||||
|
||||
Every report includes `ScanCoverage`: bounded file, per-file-text, total-text, finding and credential-finding limits plus the observed skipped counts. A limit produces a deterministic partial result and an analysis-coverage warning rather than an unqualified completeness claim or an artificial blocker.
|
||||
|
||||
## Signals
|
||||
|
||||
The v1 scan combines:
|
||||
|
||||
- language and non-empty LOC inventory;
|
||||
- test-file, test-framework, CI and coverage-artifact detection;
|
||||
- dependency manifests and lockfiles across JavaScript, Python, Go, Rust and Maven;
|
||||
- TODO/FIXME/HACK/XXX debt;
|
||||
- large files/modules and Python function-size/branch heuristics;
|
||||
- basic credential-assignment detection with values redacted;
|
||||
- bounded recent Git commit/contributor/churn information.
|
||||
|
||||
Malformed dependency manifests are represented as warning findings instead of aborting the complete scan. CI workflows are classified from supported commands as validation, build or deploy-only rather than file presence alone. Requirements includes and workspace-root lockfiles are handled safely and boundedly. Credential heuristics redact values, ignore obvious templates/environment references, and still flag real-looking fixture literals for review.
|
||||
|
||||
Each finding is enriched by a backend rule definition with confidence, why-it-matters text, recommended action, remediation class and explainable priority. The browser renders retained intelligence; it does not duplicate rule knowledge or mutate external repositories.
|
||||
|
||||
## Architecture graph
|
||||
|
||||
Python imports are parsed using the standard-library AST. Local JavaScript and TypeScript relative imports, bounded `baseUrl`/`paths` aliases from `tsconfig.json`/`jsconfig.json`, and simple local workspace package imports are resolved against known source files. Only local edges are retained; this remains heuristic source analysis, not a TypeScript compiler. The displayed graph is bounded to 160 nodes and 600 edges to keep large repositories responsive; the response explicitly indicates truncation, reports total discovered nodes/edges and distinguishes visible coupling from total coupling used in hotspot ranking. Extensionless, index and `.js`-specifier-to-TypeScript resolution are supported.
|
||||
|
||||
## Hotspot model
|
||||
|
||||
Hotspot score is deterministic and combines source size, debt markers, recent changed-line churn and local inbound/outbound coupling. The UI surfaces both the score and reasons so it is not a black-box ranking.
|
||||
|
||||
## Health and release gate
|
||||
|
||||
Category scores start at 100 and are reduced only by findings with an explicit deduction. The overall health score is the average category score. The release gate separately evaluates blocker security findings, test presence, CI, dependency hygiene (including unbounded and unpinned declarations), repository health and engineering warnings, producing `READY`, `READY WITH WARNINGS` or `BLOCKED`.
|
||||
|
||||
## History and comparison
|
||||
|
||||
Every report is serialized as a snapshot in SQLite using WAL. Lightweight scan summary columns (readiness, counts, primary signal, deltas and triage priority) drive catalog CTE queries without deserializing full reports. Comparison calculates metric/category deltas and added/resolved findings against the chronological previous scan of the same repository identity. Findings carry stable semantic fingerprints so line movement does not appear as a new risk. Trend endpoints return bounded chronological snapshots for UI visualization. By default, up to 200 snapshots are retained per repository, the latest 30 remain detailed, older retained payloads compact to summary evidence, and snapshots older than 365 days are removed. v1 path history is backfilled into a local catalog identity and is never deleted solely for migration.
|
||||
|
||||
## Reporting
|
||||
|
||||
RepoPulse can export the complete JSON report or a standalone HTML report. HTML output escapes repository-derived text, export headers use sanitized RFC 5987 filenames, and neither format includes detected secret values or Gitea credentials. A root `.repopulse.yml`/`.yaml` may configure automatic scanning, minimum health, blocker allowance, required tests/CI and finding-code suppressions. Suppressed observations remain visible and are excluded from deductions and gate blockers.
|
||||
|
||||
## Job state and failure isolation
|
||||
|
||||
The persisted queue records repository, trigger, requested/actual revision, phase, progress, attempt, timestamps, resulting scan and sanitized error information. Claims use `BEGIN IMMEDIATE` plus a status guard, so two processes cannot own the same queued job. They move through `queued → acquiring_source → scanning → analyzing → persisting → completed` or an isolated `failed` outcome. A heartbeat renews each active lease; only a crashed worker’s expired lease returns work to `queued` for recovery. Older unstarted revisions become `superseded`; explicit cancellation states remain representable. One repository failure does not terminate the worker loop or affect other queued repositories.
|
||||
|
||||
## Operator maintenance mode
|
||||
|
||||
The `app_state` key `maintenance_mode` durably records whether operator maintenance is enabled; it uses the existing state table and does not change the SQLite schema version. Operator enter/exit endpoints require `REPOPULSE_MAINTENANCE_TOKEN` (or the existing general operator token on legacy installations). Entering is serialized against API mutations and catalog synchronization so an accepted external write completes before the pause is committed. While paused, mutation requests and webhooks return `423 Locked`; the scheduler skips heartbeat writes, Gitea synchronization, queue claims and new scan-job enqueue operations. Existing queued jobs remain queued for later resumption. A job claimed before the mode transition may complete and persist its result. Queue depth, active count, active-drain state and full-drain state are reported by the authenticated maintenance endpoint and the read-only meta endpoint. A malformed persisted maintenance value fails closed as paused.
|
||||
|
||||
## Operational configuration
|
||||
|
||||
`REPOPULSE_GITEA_URL` and `REPOPULSE_GITEA_TOKEN` enable the provider. `REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS`, `REPOPULSE_GITEA_BRANCH_WORKERS`, `REPOPULSE_MAX_QUEUED_SCANS`, `REPOPULSE_JOB_LEASE_SECONDS`, scan-budget and retention variables, and `REPOPULSE_REPOSITORY_CACHE` bound reconciliation, queue depth, active leases, analysis and cache placement. The durable Gitea runner deliberately has one worker; `/api/v1/meta` exposes requested versus actual capacity. `REPOPULSE_GITEA_WEBHOOK_SECRET` enables signed default-branch push intake; non-push, non-default-branch, invalid and branch-deletion events are acknowledged without scheduling work. `REPOPULSE_OPERATOR_TOKEN` protects mutating endpoints when an authenticated proxy perimeter is not the selected boundary. `/api/v1/meta` exposes repository state counts, queue depth, storage/retention metrics, recovery status, worker heartbeat, connector URL (without token) and last sync result.
|
||||
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 767 KiB |
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
const now = '2026-09-14T08:00:00.000Z'
|
||||
const policy = { auto_scan: true, min_health_score: 0, max_blockers: 0, require_tests: false, require_ci: false, suppressions: [] }
|
||||
const guidance = {
|
||||
remediation_class: 'dependency reproducibility', confidence: 'high',
|
||||
why: 'A deterministic package-manager lockfile was not found for this workspace.',
|
||||
recommended_action: 'Commit the package-manager lockfile used by the build.',
|
||||
priority: 800, priority_rationale: 'Blocker signal; retained rule evidence.',
|
||||
}
|
||||
const finding = { code: 'dependency.no-lockfile', category: 'Dependencies', severity: 'blocker', message: 'No lockfile was retained for the declared dependencies.', path: 'package.json', line: 4, deduction: 8, fingerprint: 'finding-alpha', suppressed: false, guidance }
|
||||
const report = {
|
||||
id: 'scan-alpha', repository_path: '/scan/Alpha', repository_name: 'Alpha',
|
||||
source: { origin: 'gitea', provider: 'gitea', repository_id: 'gitea:1', repository_full_name: 'Jens/Alpha', branch: 'main', requested_revision: 'a'.repeat(40), actual_revision: 'a'.repeat(40), trigger: 'manual' },
|
||||
policy, scanned_at: now, duration_ms: 42, metrics: { files: 5, source_files: 2, lines_of_code: 20, test_files: 1, todo: 0, fixme: 0, hack: 0, xxx: 0, dependency_manifests: 1, declared_dependencies: 2, large_files: 0, large_source_files: 0 },
|
||||
languages: { TypeScript: 20 }, category_scores: { Architecture: 100, Testing: 100, Maintainability: 100, Documentation: 100, Dependencies: 92, 'Repository hygiene': 100, 'Security basics': 100 },
|
||||
health_score: 88, release_readiness: 'BLOCKED', findings: [finding],
|
||||
tests: { frameworks: ['Vitest'], coverage_artifacts: [], ci_workflows: ['.gitea/workflows/validate.yml'], ci_validation_workflows: ['.gitea/workflows/validate.yml'], ci_build_workflows: ['.gitea/workflows/validate.yml'], ci_deploy_only_workflows: [], coverage_evidence: ['.gitea/workflows/validate.yml'], test_source_ratio: 50 },
|
||||
git: { available: true, commit_count: 3, contributor_count: 1, latest_commit_at: now, tracked_files: 5, renamed_files: [] }, dependencies: { manifests: ['package.json'], lockfiles: [], declared_dependencies: 2, unbounded_dependencies: 0, unpinned_dependencies: 0, duplicate_dependencies: [], included_requirements: [] },
|
||||
architecture: { nodes: [{ id: 'src/app.ts', label: 'app.ts', path: 'src/app.ts', language: 'TypeScript', inbound: 0, outbound: 0, visible_inbound: 0, visible_outbound: 0, hotspot_score: 10 }], edges: [], clusters: { src: 1 }, truncated: false, total_nodes: 1, total_edges: 0 }, hotspots: [],
|
||||
release_gate: { status: 'BLOCKED', checks: [{ key: 'security', label: 'Security basics', state: 'pass', detail: 'No secret findings' }, { key: 'dependencies', label: 'Dependency hygiene', state: 'block', detail: 'Lockfile missing' }] },
|
||||
coverage: { limits: { max_files: 10000, max_text_bytes_per_file: 2000000, max_findings: 2000, max_secret_findings: 20 }, discovered_files: 5, scanned_files: 5, skipped_files_by_budget: 0, skipped_files_by_size: 0, text_bytes_scanned: 256, truncated: false, notes: [] }, compacted: false,
|
||||
}
|
||||
function repository(id, fullName, readiness = 'READY') {
|
||||
const name = fullName.split('/')[1]
|
||||
const blocked = readiness === 'BLOCKED'
|
||||
return { id, provider: 'gitea', provider_repository_id: id.slice(-1), owner: 'Jens', name, full_name: fullName, description: `${name} fixture repository`, html_url: null, clone_url: null, default_branch: 'main', private: true, archived: false, available: true, latest_revision: 'a'.repeat(40), latest_revision_at: now, last_synced_at: now, sync_error: null, state: 'current', stale: false, active_job_id: null, last_scan_id: id === 'gitea:1' ? report.id : null, last_scan_at: id === 'gitea:1' ? now : null, last_scan_health: id === 'gitea:1' ? 88 : 96, last_scan_readiness: readiness, last_scan_revision: 'a'.repeat(40), last_scan_blockers: blocked ? 1 : 0, last_scan_warnings: 0, last_scan_primary_signal: blocked ? { code: finding.code, category: finding.category, severity: finding.severity, message: finding.message, count: 1, guidance } : null, last_scan_health_delta: 0, last_scan_added_findings: 0, last_scan_resolved_findings: 0, last_scan_priority: blocked ? 800 : 0, last_scan_priority_rationale: blocked ? guidance.priority_rationale : null, policy }
|
||||
}
|
||||
const repositories = [
|
||||
repository('gitea:1', 'Jens/Alpha', 'BLOCKED'),
|
||||
repository('gitea:2', 'Jens/Beta'),
|
||||
...Array.from({ length: 23 }, (_unused, index) => repository(`gitea:${index + 3}`, `Jens/Gamma-${String(index + 1).padStart(2, '0')}`)),
|
||||
]
|
||||
let jobPolls = 0
|
||||
const job = { id: 'job-alpha', repository_id: 'gitea:1', status: 'queued', trigger: 'manual', requested_revision: 'a'.repeat(40), actual_revision: null, phase: 'queued', progress: 0, message: 'Waiting for an available worker', created_at: now, started_at: null, updated_at: now, finished_at: null, duration_ms: null, scan_id: null, error_code: null, error_detail: null, attempt: 1, deduplicated: false }
|
||||
function send(response, status, body, type = 'application/json') { response.writeHead(status, { 'Content-Type': type }); response.end(type === 'application/json' ? JSON.stringify(body) : body) }
|
||||
createServer((request, response) => {
|
||||
const url = new URL(request.url, 'http://127.0.0.1:8001')
|
||||
const path = decodeURIComponent(url.pathname)
|
||||
if (path === '/health') return send(response, 200, { status: 'ok' })
|
||||
if (path === '/api/v1/meta') return send(response, 200, { name: 'RepoPulse', version: '1.5.0', stage: 'v1.5.0', scan_root_restricted: true, scan_root: '/scan', max_concurrent_scans: 1, requested_max_concurrent_scans: 1, repository_count: repositories.length, repository_states: { current: 2 }, current_repositories: 2, stale_repositories: 0, queue_depth: 0, active_jobs: 0, completed_jobs: 1, failed_jobs: 0, worker_heartbeat: now, gitea: { configured: true, url: 'http://gitea.local', last_sync: { synced_at: now } }, storage: { database_bytes: 1024, journal_bytes: 0, mirror_count: 2, mirror_cache_bytes: 2048, worktree_count: 0, scan_count: 1, compacted_scan_count: 0, oldest_scan_at: now, newest_scan_at: now, history_per_repository: 200, full_reports_per_repository: 30, report_retention_days: 365 }, database_recovery: { status: 'healthy' }, mutation_authentication: 'perimeter_required' })
|
||||
if (path === '/api/v1/repositories' && request.method === 'GET') return send(response, 200, repositories)
|
||||
if (path === '/api/v1/repositories/sync' && request.method === 'POST') return send(response, 200, { provider: 'gitea', discovered: 2, created: 0, updated: 2, unavailable: 0, errors: [], partial: false, branch_failures: 0, listing_complete: true, synced_at: now })
|
||||
if (path === '/api/v1/repositories/gitea:1' && request.method === 'GET') return send(response, 200, { ...repositories[0], latest_scan: report })
|
||||
if (path === '/api/v1/repositories/gitea:2' && request.method === 'GET') return send(response, 200, { ...repositories[1], latest_scan: null })
|
||||
if (path === '/api/v1/repositories/gitea:1/scans' && request.method === 'GET') return send(response, 200, [{ id: report.id, repository_id: 'gitea:1', scanned_at: now, branch: 'main', requested_revision: report.source.requested_revision, actual_revision: report.source.actual_revision, trigger: 'manual', health_score: 88, release_readiness: 'BLOCKED', findings: 1, duration_ms: 42 }])
|
||||
if (path === '/api/v1/repositories/gitea:2/scans' && request.method === 'GET') return send(response, 200, [])
|
||||
if (path.endsWith('/trend')) return send(response, 200, [{ scan_id: report.id, scanned_at: now, health_score: 88, findings: 1, blockers: 1, warnings: 0, debt_markers: 0 }])
|
||||
if (path === '/api/v1/scans/scan-alpha/comparison') return send(response, 200, { current_scan_id: report.id, previous_scan_id: null, health_score_delta: null, category_score_deltas: {}, metric_deltas: {}, added_findings: [], resolved_findings: [] })
|
||||
if (path === '/api/v1/repositories/gitea:1/scans' && request.method === 'POST') { jobPolls = 0; return send(response, 202, job) }
|
||||
if (path === '/api/v1/scan-jobs/job-alpha') { jobPolls += 1; if (jobPolls < 2) return send(response, 200, { ...job, status: 'scanning', phase: 'scanning', progress: 40, message: 'Running deterministic repository analysis' }); return send(response, 200, { ...job, status: 'completed', phase: 'completed', progress: 100, message: 'Scan completed', scan_id: report.id, actual_revision: report.source.actual_revision, finished_at: now, duration_ms: 42 }) }
|
||||
if (path === '/api/v1/repositories/gitea:1/scans/scan-alpha') return send(response, 200, report)
|
||||
if (path === '/api/v1/scans/scan-alpha/export') return send(response, 200, JSON.stringify(report), 'application/json')
|
||||
return send(response, 404, { detail: `Unhandled mock endpoint: ${path}` })
|
||||
}).listen(8001, '127.0.0.1')
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('portfolio workflow covers navigation, remediation, a scan lifecycle and export', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: /portfolio signal/i })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Remediation Center' })).toBeVisible()
|
||||
await expect(page.getByText('A deterministic package-manager lockfile was not found for this workspace.')).toBeVisible()
|
||||
await page.locator('.remediation-center').getByRole('button', { name: /Jens\/Alpha/ }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Jens/Alpha' })).toBeVisible()
|
||||
await page.getByRole('link', { name: 'Dashboard', exact: true }).click()
|
||||
|
||||
await page.getByRole('button', { name: /open repository explorer/i }).click()
|
||||
await expect(page.getByText('25 matching 25 repositories')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Visual grid' }).click()
|
||||
await page.getByRole('button', { name: 'Pin Jens/Alpha' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Remove Jens/Alpha from pinned repositories' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Table' }).click()
|
||||
await expect(page.getByText('Page 1 / 2')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Next' }).click()
|
||||
await expect(page.getByText('Page 2 / 2')).toBeVisible()
|
||||
await page.getByLabel('Filter by readiness').selectOption('blocked')
|
||||
await expect(page.getByText('1 matching 25 repositories')).toBeVisible()
|
||||
await page.getByRole('link', { name: 'Open Jens/Alpha' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Jens/Alpha' })).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: /findings/i }).click()
|
||||
await expect(page.getByText('No lockfile was retained for the declared dependencies.')).toBeVisible()
|
||||
await page.getByRole('link', { name: /architecture/i }).click()
|
||||
await expect(page.getByText('Local import and dependency topology')).toBeVisible()
|
||||
await page.getByRole('link', { name: /history/i }).click()
|
||||
await expect(page.getByText('Repository health over time')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||
await expect(page.getByText('Repository analysis completed.')).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByText('Reports').click()
|
||||
const download = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: 'Export JSON' }).click()
|
||||
await expect(download).resolves.toBeTruthy()
|
||||
|
||||
await page.keyboard.press('Control+k')
|
||||
await page.getByPlaceholder('Search repositories…').fill('Beta')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByRole('heading', { name: 'Jens/Beta', exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const vite = fileURLToPath(new URL('../node_modules/vite/bin/vite.js', import.meta.url))
|
||||
const child = spawn(process.execPath, [vite, '--host', '127.0.0.1', '--port', '4173'], {
|
||||
cwd: new URL('..', import.meta.url),
|
||||
env: { ...process.env, REPOPULSE_API_PROXY: 'http://127.0.0.1:8001' },
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('exit', (code) => process.exit(code ?? 0))
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="RepoPulse is a premium repository intelligence cockpit for portfolio health, scan operations and architecture signals." />
|
||||
<meta name="theme-color" content="#0d2130" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>RepoPulse — Portfolio intelligence</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://backend:8000/health;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "repopulse-frontend",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.8.5",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
fullyParallel: false,
|
||||
workers: 6,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: '1920x1080', use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } } },
|
||||
{ name: '1680x900', use: { ...devices['Desktop Chrome'], viewport: { width: 1680, height: 900 } } },
|
||||
{ name: '1440x900', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
|
||||
{ name: '1366x768', use: { ...devices['Desktop Chrome'], viewport: { width: 1366, height: 768 } } },
|
||||
{ name: '1024x768', use: { ...devices['Desktop Chrome'], viewport: { width: 1024, height: 768 } } },
|
||||
{ name: '390x844', use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 } } },
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
command: 'node e2e/mock-api.mjs',
|
||||
url: 'http://127.0.0.1:8001/health',
|
||||
reuseExistingServer: false,
|
||||
timeout: 15_000,
|
||||
},
|
||||
{
|
||||
command: 'node e2e/start-vite.mjs',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 228 48" role="img" aria-label="RepoPulse Repository Intelligence">
|
||||
<rect width="48" height="48" rx="12" fill="#e6f4f4"/>
|
||||
<path d="M10 14h8l3.1 3H38M10 34h8l3.1-3H38M18.4 24h3.2l2-4.5 3.3 9 2-4.5H38" fill="none" stroke="#166d70" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="10" cy="14" r="2.7" fill="#166d70"/><circle cx="10" cy="34" r="2.7" fill="#166d70"/><circle cx="38" cy="24" r="2.7" fill="#166d70"/>
|
||||
<text x="62" y="22" fill="#102331" font-family="Arial,Helvetica,sans-serif" font-size="20" font-weight="700" letter-spacing="-.5">RepoPulse</text>
|
||||
<text x="62" y="36" fill="#567080" font-family="Arial,Helvetica,sans-serif" font-size="8" font-weight="700" letter-spacing="1.4">REPOSITORY INTELLIGENCE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 818 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 228 48" role="img" aria-label="RepoPulse Repository Intelligence">
|
||||
<rect width="48" height="48" rx="12" fill="#0d2130"/>
|
||||
<path d="M10 14h8l3.1 3H38M10 34h8l3.1-3H38M18.4 24h3.2l2-4.5 3.3 9 2-4.5H38" fill="none" stroke="#91ebe0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="10" cy="14" r="2.7" fill="#91ebe0"/><circle cx="10" cy="34" r="2.7" fill="#91ebe0"/><circle cx="38" cy="24" r="2.7" fill="#91ebe0"/>
|
||||
<text x="62" y="22" fill="#eff7ff" font-family="Arial,Helvetica,sans-serif" font-size="20" font-weight="700" letter-spacing="-.5">RepoPulse</text>
|
||||
<text x="62" y="36" fill="#91a5bb" font-family="Arial,Helvetica,sans-serif" font-size="8" font-weight="700" letter-spacing="1.4">REPOSITORY INTELLIGENCE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 818 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<path d="M5 8.5h7.1l2.2 2.2H27M5 23.5h7.1l2.2-2.2H27M12.4 16h2.5l1.55-3.4 2.15 6.8 1.4-3.4H27" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="5" cy="8.5" r="2" fill="currentColor"/><circle cx="5" cy="23.5" r="2" fill="currentColor"/><circle cx="27" cy="16" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#0d2130"/>
|
||||
<path d="M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25" fill="none" stroke="#91ebe0" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="7" cy="9.5" r="2" fill="#91ebe0"/><circle cx="7" cy="22.5" r="2" fill="#91ebe0"/><circle cx="25" cy="16" r="2" fill="#91ebe0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 460 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#0d2130"/>
|
||||
<path d="M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25" fill="none" stroke="#91ebe0" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="7" cy="9.5" r="2" fill="#91ebe0"/><circle cx="7" cy="22.5" r="2" fill="#91ebe0"/><circle cx="25" cy="16" r="2" fill="#91ebe0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 460 B |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "RepoPulse — Repository Intelligence",
|
||||
"short_name": "RepoPulse",
|
||||
"description": "Portfolio-level repository intelligence from deterministic scans.",
|
||||
"display": "standalone",
|
||||
"background_color": "#07111c",
|
||||
"theme_color": "#0d2130",
|
||||
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }]
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import App from './App'
|
||||
import { api, type ScanJob } from './api'
|
||||
import { testComparison, testDetail, testMeta, testRepository, testScans, testTrend } from './test-fixtures'
|
||||
|
||||
const repository = testRepository()
|
||||
const secondRepository = testRepository('gitea:65', 'Jens/AgentTestLab', { last_scan_health: 93, last_scan_readiness: 'READY' })
|
||||
const queuedJob: ScanJob = {
|
||||
id: 'job-1',
|
||||
repository_id: repository.id,
|
||||
status: 'queued',
|
||||
trigger: 'manual',
|
||||
requested_revision: 'revision-1',
|
||||
actual_revision: null,
|
||||
phase: 'queued',
|
||||
progress: 0,
|
||||
message: 'Analysis queued',
|
||||
created_at: '2026-09-12T20:00:00Z',
|
||||
started_at: null,
|
||||
updated_at: '2026-09-12T20:00:00Z',
|
||||
finished_at: null,
|
||||
duration_ms: null,
|
||||
scan_id: null,
|
||||
error_code: null,
|
||||
error_detail: null,
|
||||
attempt: 0,
|
||||
deduplicated: false,
|
||||
}
|
||||
|
||||
let repositoriesMock: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/repositories')
|
||||
window.localStorage.clear()
|
||||
Object.defineProperty(window, 'scrollTo', { configurable: true, value: vi.fn() })
|
||||
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => { callback(0); return 0 } })
|
||||
|
||||
repositoriesMock = vi.spyOn(api, 'repositories').mockResolvedValue([repository, secondRepository])
|
||||
vi.spyOn(api, 'meta').mockResolvedValue(testMeta)
|
||||
vi.spyOn(api, 'repository').mockImplementation(async (id) => id === secondRepository.id ? testDetail(secondRepository) : testDetail(repository))
|
||||
vi.spyOn(api, 'repositoryScans').mockResolvedValue(testScans)
|
||||
vi.spyOn(api, 'repositoryTrend').mockResolvedValue(testTrend)
|
||||
vi.spyOn(api, 'comparison').mockResolvedValue(testComparison)
|
||||
vi.spyOn(api, 'scanJob').mockResolvedValue(queuedJob)
|
||||
vi.spyOn(api, 'enqueueRepositoryScan').mockResolvedValue(queuedJob)
|
||||
vi.spyOn(api, 'exportReport').mockResolvedValue('<html>report</html>')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('RepoPulse workspace shell', () => {
|
||||
it('searches, filters and opens a repository detail route', async () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: /Repositories/ })
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, owner or full name'), { target: { value: 'RepoPulse' } })
|
||||
expect(screen.getByRole('region', { name: 'Repository filters' }).textContent).toContain('1 matching 2 repositories')
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Filter by readiness' }), { target: { value: 'blocked' } })
|
||||
expect(screen.getByRole('link', { name: 'Open Jens/RepoPulse' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Open Jens/RepoPulse' }))
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Jens/RepoPulse' })).toBeTruthy()
|
||||
expect(window.location.pathname).toBe('/repositories/gitea%3A64/overview')
|
||||
expect(screen.getByRole('button', { name: 'Scan now' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('persists pinned repositories and opens a recent quick-switch selection', async () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: /Repositories/ })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pin Jens/RepoPulse' }))
|
||||
await waitFor(() => expect(window.localStorage.getItem('repopulse:pinned-repositories')).toContain('gitea:64'))
|
||||
|
||||
fireEvent.click(document.querySelector('.shortcut-button') as HTMLElement)
|
||||
const switcher = await screen.findByPlaceholderText('Search repositories…')
|
||||
fireEvent.change(switcher, { target: { value: 'AgentTestLab' } })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Jens/AgentTestLab' })).toBeTruthy()
|
||||
expect(window.location.pathname).toBe('/repositories/gitea%3A65/overview')
|
||||
expect(window.localStorage.getItem('repopulse:recent-repositories')).toContain('gitea:65')
|
||||
})
|
||||
|
||||
it('exposes compact scan and report actions on detail', async () => {
|
||||
window.history.replaceState({}, '', '/repositories/gitea%3A64/overview')
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: 'Scan now' })
|
||||
|
||||
fireEvent.click(screen.getByText('Reports', { exact: true }))
|
||||
expect(screen.getByRole('button', { name: 'Export HTML' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Export JSON' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByText('More', { exact: true }))
|
||||
expect(screen.getByRole('button', { name: 'Force rescan' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'View scan runs' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Advanced info' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan now' }))
|
||||
await waitFor(() => expect(api.enqueueRepositoryScan).toHaveBeenCalledWith('gitea:64', false))
|
||||
})
|
||||
|
||||
it('shows an actionable empty detail state for an unscanned repository', async () => {
|
||||
const emptyRepository = testRepository('gitea:66', 'Jens/EmptyRepo', {
|
||||
state: 'never_scanned',
|
||||
last_scan_id: null,
|
||||
last_scan_at: null,
|
||||
last_scan_health: null,
|
||||
last_scan_readiness: null,
|
||||
last_scan_revision: null,
|
||||
})
|
||||
repositoriesMock.mockResolvedValue([emptyRepository])
|
||||
vi.mocked(api.repository).mockResolvedValue({ ...emptyRepository, latest_scan: null })
|
||||
window.history.replaceState({}, '', '/repositories/gitea%3A66/overview')
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Jens/EmptyRepo has no persisted scan yet.')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Scan repository' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { api, type AppMeta, type RepositorySummary, type ScanComparison, type ScanJob, type ScanReport, type ScanSummary, type TrendPoint } from './api'
|
||||
import { filterRepositories, orderSwitcherRepositories, paginateRepositories, readinessLabel, repositoryReadiness, sortRepositories, stateLabel, type CatalogFilters, type CatalogSort, type CatalogSortKey, type Readiness } from './catalog'
|
||||
import { ArchitectureView } from './components/ArchitectureView'
|
||||
import { BrandLockup, BrandMark } from './components/Brand'
|
||||
import { DashboardView } from './components/DashboardView'
|
||||
import { DependenciesView } from './components/DependenciesView'
|
||||
import { FindingsView } from './components/FindingsView'
|
||||
import { HistoryView } from './components/HistoryView'
|
||||
import { HotspotsView } from './components/HotspotsView'
|
||||
import { OverviewView } from './components/OverviewView'
|
||||
import { RepositoryVisual } from './components/RepositoryVisual'
|
||||
import { ScanRunsView } from './components/ScanRunsView'
|
||||
import { DETAIL_VIEWS, parseRoute, routePath, type DetailView, type Route } from './navigation'
|
||||
|
||||
const ACTIVE_JOB_STATES: ScanJob['status'][] = ['queued', 'acquiring_source', 'scanning', 'analyzing', 'persisting']
|
||||
const PINNED_STORAGE_KEY = 'repopulse:pinned-repositories'
|
||||
const RECENT_STORAGE_KEY = 'repopulse:recent-repositories'
|
||||
const CATALOG_VIEW_STORAGE_KEY = 'repopulse:catalog-view'
|
||||
const DEFAULT_FILTERS: CatalogFilters = { query: '', state: 'all', readiness: 'all', owner: 'all', branch: 'all', visibility: 'all', archived: 'active' }
|
||||
|
||||
function readIdList(key: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(key) ?? '[]')
|
||||
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function readCatalogView(): 'table' | 'grid' {
|
||||
try { return window.localStorage.getItem(CATALOG_VIEW_STORAGE_KEY) === 'grid' ? 'grid' : 'table' } catch { return 'table' }
|
||||
}
|
||||
|
||||
function writeIdList(key: string, ids: string[]) { try { window.localStorage.setItem(key, JSON.stringify(ids)) } catch { /* preferences are best effort */ } }
|
||||
function shortRevision(revision: string | null) { return revision ? revision.slice(0, 10) : 'not acquired' }
|
||||
function statusClass(value: string) { return value.replace(/_/g, '-') }
|
||||
function formatDate(value: string | null) { if (!value) return 'Never'; const date = new Date(value); return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) }
|
||||
function formatRelativeDate(value: string | null) { if (!value) return 'Never scanned'; const difference = Date.now() - new Date(value).getTime(); if (Number.isNaN(difference)) return 'Unknown'; if (difference < 3_600_000) return `${Math.max(1, Math.floor(difference / 60_000))}m ago`; if (difference < 86_400_000) return `${Math.floor(difference / 3_600_000)}h ago`; return `${Math.floor(difference / 86_400_000)}d ago` }
|
||||
function downloadText(filename: string, content: string, mime: string) { const url = URL.createObjectURL(new Blob([content], { type: mime })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; anchor.style.display = 'none'; document.body.appendChild(anchor); anchor.click(); window.setTimeout(() => { anchor.remove(); URL.revokeObjectURL(url) }, 0) }
|
||||
function reportFilename(repositoryName: string, format: 'html' | 'json') { const safeName = repositoryName.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]/g, '_').replace(/[ .]+$/g, '').slice(0, 100).replace(/[ .]+$/g, '') || 'repository'; return `repopulse-${safeName}.${format}` }
|
||||
function detailViewLabel(view: DetailView, report: ScanReport | null) { if (view === 'overview') return 'Overview'; if (view === 'findings') return `Findings (${report?.findings.filter((finding) => !finding.suppressed).length ?? 0})`; if (view === 'architecture') return `Architecture (${report?.architecture.nodes.length ?? 0})`; if (view === 'hotspots') return `Hotspots (${report?.hotspots.length ?? 0})`; if (view === 'dependencies') return 'Dependencies'; if (view === 'history') return 'History'; return 'Scan runs' }
|
||||
|
||||
export default function App() {
|
||||
const [route, setRoute] = useState<Route>(() => parseRoute(window.location.pathname))
|
||||
const [repositories, setRepositories] = useState<RepositorySummary[]>([])
|
||||
const [catalogLoading, setCatalogLoading] = useState(true)
|
||||
const [selectedDetailsLoading, setSelectedDetailsLoading] = useState(false)
|
||||
const [scanSummaries, setScanSummaries] = useState<ScanSummary[]>([])
|
||||
const [report, setReport] = useState<ScanReport | null>(null)
|
||||
const [comparison, setComparison] = useState<ScanComparison | null>(null)
|
||||
const [trend, setTrend] = useState<TrendPoint[]>([])
|
||||
const [path, setPath] = useState('')
|
||||
const [job, setJob] = useState<ScanJob | null>(null)
|
||||
const [meta, setMeta] = useState<AppMeta | null>(null)
|
||||
const [standaloneReport, setStandaloneReport] = useState<ScanReport | null>(null)
|
||||
const [pinnedIds, setPinnedIds] = useState<string[]>(() => readIdList(PINNED_STORAGE_KEY))
|
||||
const [recentIds, setRecentIds] = useState<string[]>(() => readIdList(RECENT_STORAGE_KEY))
|
||||
const [catalogFilters, setCatalogFilters] = useState<CatalogFilters>(DEFAULT_FILTERS)
|
||||
const [catalogSort, setCatalogSort] = useState<CatalogSort>({ key: 'name', direction: 'asc' })
|
||||
const [catalogPage, setCatalogPage] = useState(1)
|
||||
const [catalogPageSize, setCatalogPageSize] = useState(24)
|
||||
const [catalogView, setCatalogView] = useState<'table' | 'grid'>(readCatalogView)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [catalogBusy, setCatalogBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [switcherOpen, setSwitcherOpen] = useState(false)
|
||||
const [switcherQuery, setSwitcherQuery] = useState('')
|
||||
const [switcherIndex, setSwitcherIndex] = useState(0)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const switcherInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const selectedId = route.repositoryId ?? null
|
||||
const selectedRepository = useMemo(() => repositories.find((repository) => repository.id === selectedId) ?? null, [repositories, selectedId])
|
||||
const activeJob = job && job.repository_id === selectedId && ACTIVE_JOB_STATES.includes(job.status) ? job : null
|
||||
const navigate = useCallback((next: Route, replace = false) => { const href = routePath(next); if (window.location.pathname !== href) window.history[replace ? 'replaceState' : 'pushState']({}, '', href); setRoute(next); setAdvancedOpen(false); window.scrollTo?.({ top: 0, behavior: 'auto' }) }, [])
|
||||
const handleNavigation = useCallback((event: React.MouseEvent<HTMLAnchorElement>, next: Route) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); navigate(next) }, [navigate])
|
||||
const refreshCatalog = useCallback(async () => { const next = await api.repositories(); setRepositories(next); return next }, [])
|
||||
const refreshMeta = useCallback(async () => { try { setMeta(await api.meta()) } catch { /* catalog navigation must remain usable */ } }, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setRoute(parseRoute(window.location.pathname))
|
||||
window.addEventListener('popstate', onPopState)
|
||||
void (async () => { setCatalogLoading(true); try { await refreshCatalog() } catch { try { const legacy = await api.recent(); setStandaloneReport(legacy[0] ?? null); setPath(legacy[0]?.repository_path ?? ''); setError(legacy.length ? 'Repository catalog is unavailable; showing the last local report in Settings.' : 'Could not load the repository catalog.') } catch (err) { setError(err instanceof Error ? err.message : 'Could not load the repository catalog.') } } finally { setCatalogLoading(false) } })()
|
||||
void refreshMeta()
|
||||
return () => window.removeEventListener('popstate', onPopState)
|
||||
}, [refreshCatalog, refreshMeta])
|
||||
|
||||
useEffect(() => { document.title = route.page === 'dashboard' ? 'RepoPulse — Portfolio intelligence' : 'RepoPulse — Repository intelligence' }, [route.page])
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setReport(null); setScanSummaries([]); setTrend([]); setComparison(null); setSelectedDetailsLoading(false); return }
|
||||
let live = true
|
||||
setSelectedDetailsLoading(true); setReport(null); setComparison(null); setTrend([])
|
||||
void Promise.all([api.repository(selectedId), api.repositoryScans(selectedId), api.repositoryTrend(selectedId)]).then(([detail, summaries, nextTrend]) => { if (!live) return; setScanSummaries(summaries); setTrend(nextTrend); setReport(detail.latest_scan); setError(null); if (detail.latest_scan?.repository_path) setPath(detail.latest_scan.repository_path) }).catch((err) => { if (live) setError(err instanceof Error ? err.message : 'Could not load repository details.') }).finally(() => { if (live) setSelectedDetailsLoading(false) })
|
||||
return () => { live = false }
|
||||
}, [selectedId])
|
||||
useEffect(() => { if (!report) { setComparison(null); return }; let live = true; void api.comparison(report.id).then((next) => { if (live) setComparison(next) }).catch(() => { if (live) setComparison(null) }); return () => { live = false } }, [report])
|
||||
useEffect(() => { if (!selectedRepository?.active_job_id || job?.id === selectedRepository.active_job_id) return; let live = true; void api.scanJob(selectedRepository.active_job_id).then((next) => { if (live) setJob(next) }).catch(() => undefined); return () => { live = false } }, [job?.id, selectedRepository])
|
||||
useEffect(() => {
|
||||
const jobId = activeJob?.id
|
||||
if (!jobId || !selectedId) return
|
||||
let live = true
|
||||
let failures = 0
|
||||
let timer: number | undefined
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await api.scanJob(jobId)
|
||||
if (!live) return
|
||||
failures = 0
|
||||
if (next.status === 'completed' && next.scan_id) {
|
||||
const nextReport = await api.repositoryScan(next.repository_id, next.scan_id)
|
||||
if (!live) return
|
||||
const [summaries, nextTrend] = await Promise.all([api.repositoryScans(next.repository_id), api.repositoryTrend(next.repository_id)])
|
||||
if (!live) return
|
||||
await refreshCatalog()
|
||||
if (!live) return
|
||||
setJob(next)
|
||||
setReport(nextReport)
|
||||
setScanSummaries(summaries)
|
||||
setTrend(nextTrend)
|
||||
setNotice(next.deduplicated ? 'The selected revision was already analyzed; showing the existing report.' : 'Repository analysis completed.')
|
||||
return
|
||||
}
|
||||
setJob(next)
|
||||
if (next.status === 'failed') { setError(next.error_detail || 'Repository analysis failed.'); return }
|
||||
} catch (err) {
|
||||
failures += 1
|
||||
if (!live) return
|
||||
if (failures >= 5) { setError(err instanceof Error ? `${err.message} Progress polling paused after five failed attempts.` : 'Progress polling paused after five failed attempts.'); return }
|
||||
}
|
||||
if (live) timer = window.setTimeout(() => void poll(), 1500)
|
||||
}
|
||||
void poll()
|
||||
return () => { live = false; if (timer) window.clearTimeout(timer) }
|
||||
}, [activeJob?.id, refreshCatalog, selectedId])
|
||||
useEffect(() => { if (selectedId) setRecentIds((current) => [selectedId, ...current.filter((id) => id !== selectedId)].slice(0, 8)) }, [selectedId])
|
||||
useEffect(() => writeIdList(PINNED_STORAGE_KEY, pinnedIds), [pinnedIds])
|
||||
useEffect(() => writeIdList(RECENT_STORAGE_KEY, recentIds), [recentIds])
|
||||
useEffect(() => { try { window.localStorage.setItem(CATALOG_VIEW_STORAGE_KEY, catalogView) } catch { /* preference only */ } }, [catalogView])
|
||||
|
||||
const filteredRepositories = useMemo(() => filterRepositories(repositories, catalogFilters), [catalogFilters, repositories])
|
||||
const sortedRepositories = useMemo(() => sortRepositories(filteredRepositories, catalogSort), [catalogSort, filteredRepositories])
|
||||
const catalogWindow = useMemo(() => paginateRepositories(sortedRepositories, catalogPage, catalogPageSize), [catalogPage, catalogPageSize, sortedRepositories])
|
||||
const ownerOptions = useMemo(() => [...new Set(repositories.map((repository) => repository.owner))].sort(), [repositories])
|
||||
const branchOptions = useMemo(() => [...new Set(repositories.map((repository) => repository.default_branch))].sort(), [repositories])
|
||||
const switcherRepositories = useMemo(() => orderSwitcherRepositories(repositories, pinnedIds, recentIds, switcherQuery), [pinnedIds, recentIds, repositories, switcherQuery])
|
||||
const currentView = route.view ?? 'overview'
|
||||
useEffect(() => { if (catalogPage !== catalogWindow.page) setCatalogPage(catalogWindow.page) }, [catalogPage, catalogWindow.page])
|
||||
useEffect(() => { if (switcherOpen) { setSwitcherIndex(0); window.requestAnimationFrame(() => switcherInputRef.current?.focus()) } }, [switcherOpen])
|
||||
useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); setSwitcherOpen(true); return }; if (!switcherOpen) return; if (event.key === 'Escape') { event.preventDefault(); setSwitcherOpen(false) } else if (event.key === 'ArrowDown') { event.preventDefault(); setSwitcherIndex((index) => Math.min(index + 1, Math.max(0, switcherRepositories.length - 1))) } else if (event.key === 'ArrowUp') { event.preventDefault(); setSwitcherIndex((index) => Math.max(0, index - 1)) } else if (event.key === 'Enter' && switcherRepositories[switcherIndex]) { event.preventDefault(); selectRepository(switcherRepositories[switcherIndex]); setSwitcherOpen(false) } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown) }, [switcherIndex, switcherOpen, switcherRepositories])
|
||||
|
||||
const selectRepository = (repository: RepositorySummary) => { setError(null); setNotice(null); navigate({ page: 'repositories', repositoryId: repository.id, view: 'overview' }) }
|
||||
const togglePinned = (repositoryId: string) => setPinnedIds((current) => current.includes(repositoryId) ? current.filter((id) => id !== repositoryId) : [repositoryId, ...current])
|
||||
const exploreReadiness = (readiness: Readiness) => { setCatalogFilters({ ...DEFAULT_FILTERS, readiness }); setCatalogPage(1); navigate({ page: 'repositories' }) }
|
||||
const updateCatalogFilter = <K extends keyof CatalogFilters>(key: K, value: CatalogFilters[K]) => { setCatalogFilters((current) => ({ ...current, [key]: value })); setCatalogPage(1) }
|
||||
const setCatalogSortKey = (key: CatalogSortKey) => { setCatalogSort((current) => current.key === key ? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' } : { key, direction: key === 'name' || key === 'state' ? 'asc' : 'desc' }); setCatalogPage(1) }
|
||||
const analyzeRepository = async (repository: RepositorySummary, force = false) => { try { setBusy(true); setError(null); setNotice(null); const next = await api.enqueueRepositoryScan(repository.id, force); setJob(next); setNotice(next.deduplicated ? 'This revision is already in the analysis history.' : 'Analysis queued. Progress will update here.'); if (next.status === 'completed' && next.scan_id) setReport(await api.repositoryScan(repository.id, next.scan_id)) } catch (err) { setError(err instanceof Error ? err.message : 'Could not queue repository analysis.') } finally { setBusy(false) } }
|
||||
const syncCatalog = async () => { try { setCatalogBusy(true); setError(null); const result = await api.syncRepositories(); await Promise.all([refreshCatalog(), refreshMeta()]); setNotice(result.partial ? `Gitea catalog partially synchronized: ${result.discovered} resolved, ${result.branch_failures ?? 0} branch lookup failure(s). Existing repository evidence was preserved.` : `Gitea catalog synchronized: ${result.discovered} repositories discovered.`) } catch (err) { setError(err instanceof Error ? err.message : 'Gitea catalog synchronization failed.') } finally { setCatalogBusy(false) } }
|
||||
const scanLocal = async (event: FormEvent) => { event.preventDefault(); try { setBusy(true); setError(null); setNotice(null); const next = await api.scan(path); setStandaloneReport(next); setPath(next.repository_path); await refreshCatalog(); setNotice(`Local analysis completed in ${next.duration_ms} ms. The result is available in Settings.`); navigate({ page: 'settings' }) } catch (err) { setError(err instanceof Error ? err.message : 'Local repository analysis failed.') } finally { setBusy(false) } }
|
||||
const selectScan = async (summary: ScanSummary) => { try { setBusy(true); setError(null); setReport(await api.repositoryScan(summary.repository_id, summary.id)); navigate({ page: 'repositories', repositoryId: summary.repository_id, view: 'overview' }) } catch (err) { setError(err instanceof Error ? err.message : 'Could not load the selected scan.') } finally { setBusy(false) } }
|
||||
const exportReport = async (format: 'html' | 'json') => { if (!report) return; try { downloadText(reportFilename(report.repository_name, format), await api.exportReport(report.id, format), format === 'html' ? 'text/html' : 'application/json') } catch (err) { setError(err instanceof Error ? err.message : 'Report export failed.') } }
|
||||
|
||||
const renderGlobalNav = () => <nav className="global-nav" aria-label="Primary navigation"><a className={route.page === 'dashboard' ? 'active' : ''} aria-current={route.page === 'dashboard' ? 'page' : undefined} href="/" onClick={(event) => handleNavigation(event, { page: 'dashboard' })}>Dashboard</a><a className={route.page === 'repositories' ? 'active' : ''} aria-current={route.page === 'repositories' ? 'page' : undefined} href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Repositories <span>{repositories.length || '—'}</span></a><a className={route.page === 'activity' ? 'active' : ''} aria-current={route.page === 'activity' ? 'page' : undefined} href="/activity" onClick={(event) => handleNavigation(event, { page: 'activity' })}>Activity</a><a className={route.page === 'queue' ? 'active' : ''} aria-current={route.page === 'queue' ? 'page' : undefined} href="/queue" onClick={(event) => handleNavigation(event, { page: 'queue' })}>Scan queue</a><a className={route.page === 'settings' ? 'active' : ''} aria-current={route.page === 'settings' ? 'page' : undefined} href="/settings" onClick={(event) => handleNavigation(event, { page: 'settings' })}>Settings</a></nav>
|
||||
const renderTopbar = () => <header className="app-header"><a className="brand-home" href="/" onClick={(event) => handleNavigation(event, { page: 'dashboard' })} aria-label="RepoPulse dashboard"><BrandLockup /></a>{renderGlobalNav()}<div className="header-actions"><button className="shortcut-button" type="button" onClick={() => setSwitcherOpen(true)} aria-keyshortcuts="Control+K Meta+K"><span>Quick switch</span><kbd>⌘K</kbd></button><button className="icon-button sync-icon" type="button" onClick={() => void syncCatalog()} disabled={catalogBusy} aria-label="Synchronize Gitea catalog" title="Synchronize Gitea catalog">↻</button></div></header>
|
||||
|
||||
const renderCatalogPage = () => <section className="page-content explorer-page" aria-labelledby="repositories-title"><div className="page-heading"><div><span className="eyebrow">Repository explorer</span><h1 id="repositories-title">Repositories <span className="count-badge">{repositories.length}</span></h1><p className="muted">High-density catalog controls when you need breadth; switch to visual identity tiles when you need pattern recognition.</p></div><div className="page-heading-actions"><button className="ghost" type="button" onClick={() => setSwitcherOpen(true)}>Quick switcher <kbd>⌘K</kbd></button><button type="button" onClick={() => void syncCatalog()} disabled={catalogBusy}>{catalogBusy ? 'Syncing…' : 'Sync Gitea catalog'}</button></div></div><section className="catalog-toolbar section-card" aria-label="Repository filters"><div className="search-control"><span className="search-glyph" aria-hidden="true">⌕</span><label className="sr-only" htmlFor="catalog-search">Search repositories</label><input id="catalog-search" value={catalogFilters.query} onChange={(event) => updateCatalogFilter('query', event.target.value)} placeholder="Search name, owner or full name" /><kbd>⌘K</kbd></div><div className="filter-grid"><label>State<select aria-label="Filter by state" value={catalogFilters.state} onChange={(event) => updateCatalogFilter('state', event.target.value as CatalogFilters['state'])}><option value="all">All states</option><option value="current">Current</option><option value="stale">Stale</option><option value="scanning">Scanning</option><option value="failed">Failed</option><option value="never_scanned">Never scanned</option><option value="archived">Archived</option></select></label><label>Readiness<select aria-label="Filter by readiness" value={catalogFilters.readiness} onChange={(event) => updateCatalogFilter('readiness', event.target.value as CatalogFilters['readiness'])}><option value="all">All readiness</option><option value="ready">Ready</option><option value="warnings">Warnings</option><option value="blocked">Blocked</option></select></label><label>Owner<select aria-label="Filter by owner" value={catalogFilters.owner} onChange={(event) => updateCatalogFilter('owner', event.target.value)}><option value="all">All owners</option>{ownerOptions.map((owner) => <option key={owner} value={owner}>{owner}</option>)}</select></label><label>Branch<select aria-label="Filter by branch" value={catalogFilters.branch} onChange={(event) => updateCatalogFilter('branch', event.target.value)}><option value="all">All branches</option>{branchOptions.map((branch) => <option key={branch} value={branch}>{branch}</option>)}</select></label><label>Visibility<select aria-label="Filter by visibility" value={catalogFilters.visibility} onChange={(event) => updateCatalogFilter('visibility', event.target.value as CatalogFilters['visibility'])}><option value="all">Any visibility</option><option value="private">Private</option><option value="public">Public</option></select></label><label>Lifecycle<select aria-label="Filter by lifecycle" value={catalogFilters.archived} onChange={(event) => updateCatalogFilter('archived', event.target.value as CatalogFilters['archived'])}><option value="active">Active only</option><option value="all">Active and archived</option><option value="archived">Archived only</option></select></label></div><div className="toolbar-footer"><span className="filter-result">{filteredRepositories.length} matching {repositories.length} repositories</span><div className="view-toggle" aria-label="Repository view"><button className={catalogView === 'table' ? 'active' : ''} type="button" onClick={() => setCatalogView('table')} aria-pressed={catalogView === 'table'}>Table</button><button className={catalogView === 'grid' ? 'active' : ''} type="button" onClick={() => setCatalogView('grid')} aria-pressed={catalogView === 'grid'}>Visual grid</button></div><button className="text-button" type="button" onClick={() => { setCatalogFilters(DEFAULT_FILTERS); setCatalogPage(1) }}>Reset filters</button></div></section>{catalogLoading ? <section className="catalog-loading" aria-label="Loading repository catalog"><span /><span /><span /><span /></section> : catalogWindow.items.length ? <>{catalogView === 'table' ? <section className="catalog-table-card section-card" aria-label="Repository catalog results"><div className="table-scroll"><table className="repository-table"><thead><tr><th><button className="sort-button" type="button" onClick={() => setCatalogSortKey('name')}>Repository <span>{catalogSort.key === 'name' ? catalogSort.direction === 'asc' ? '↑' : '↓' : '↕'}</span></button></th><th><button className="sort-button" type="button" onClick={() => setCatalogSortKey('state')}>State <span>{catalogSort.key === 'state' ? catalogSort.direction === 'asc' ? '↑' : '↓' : '↕'}</span></button></th><th>Readiness</th><th>Branch</th><th><button className="sort-button" type="button" onClick={() => setCatalogSortKey('last_sLine truncated
|
||||
|
||||
const renderDetailPage = () => {
|
||||
if (!selectedRepository) return <section className="page-content"><div className="empty-state section-card"><BrandMark size={38} label="" /><h1>This repository is not in the current catalog.</h1><p className="muted">It may have been archived, removed or the link may be stale.</p><a className="button-link" href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Back to repositories</a></div></section>
|
||||
const readiness = repositoryReadiness(selectedRepository); const revision = report?.source.actual_revision || selectedRepository.latest_revision
|
||||
return <section className="page-content detail-page" aria-labelledby="repository-title"><div className="breadcrumb-row"><a href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Repository explorer</a><span aria-hidden="true">/</span><button type="button" onClick={() => setSwitcherOpen(true)}>{selectedRepository.full_name} <span aria-hidden="true">⌄</span></button></div><section className="workspace-hero"><div className="workspace-identity"><div><span className="eyebrow">{selectedRepository.provider} repository · {selectedRepository.private ? 'private' : 'public'}</span><h1 id="repository-title">{selectedRepository.full_name}</h1><p>{selectedRepository.description || 'No repository description provided.'}</p><div className="workspace-facts"><span><code>{selectedRepository.default_branch}</code></span><span><code>{shortRevision(revision)}</code></span><span className={`readiness-label readiness-${readiness}`}><i aria-hidden="true" />{stateLabel(selectedRepository.state)}</span></div></div><div className="workspace-score"><strong>{report?.health_score ?? selectedRepository.last_scan_health ?? '—'}</strong><span>health score</span><small>{report?.release_readiness ?? readinessLabel(readiness)}</small></div></div><RepositoryVisual repository={selectedRepository} report={report} className="workspace-visual" /><div className="detail-actions"><button type="button" onClick={() => void analyzeRepository(selectedRepository)} disabled={busy || Boolean(activeJob)}>{activeJob ? 'Scanning…' : 'Scan now'}</button><details className="action-menu"><summary>Reports</summary><div className="menu-popover"><button type="button" onClick={() => void exportReport('html')} disabled={!report}>Export HTML</button><button type="button" onClick={() => void exportReport('json')} disabled={!report}>Export JSON</button></div></details><details className="action-menu"><summary>More</summary><div className="menu-popover"><button type="button" onClick={() => void analyzeRepository(selectedRepository, true)} disabled={busy || Boolean(activeJob)}>Force rescan</button><button type="button" onClick={() => navigate({ page: 'repositories', repositoryId: selectedRepository.id, view: 'scans' })}>View scan runs</button><button type="button" onClick={() => setAdvancedOpen((current) => !current)}>Advanced info</button></div></details><button className={`star-button header-star ${pinnedIds.includes(selectedRepository.id) ? 'pinned' : ''}`} type="button" onClick={() => togglePinned(selectedRepository.id)} aria-label={pinnedIds.includes(selectedRepository.id) ? 'Remove from pinned repositories' : 'Pin repository'} aria-pressed={pinnedIds.includes(selectedRepository.id)}><span aria-hidden="true">{pinnedIds.includes(selectedRepository.id) ? '★' : '☆'}</span></button></div></section>{error && <div className="error-banner" role="alert">{error}</div>}{notice && <div className="notice-banner" role="status">{notice}</div>}{activeJob && <div className="job-progress" role="status"><div><strong>{activeJob.message || 'Analysis in progress'}</strong><span>{activeJob.phase.replaceAll('_', ' ')} · {activeJob.progress}%</span></div><div className="progress-track"><i style={{ width: `${activeJob.progress}%` }} /></div></div>}<nav className="detail-tabs" aria-label="Repository analysis views">{DETAIL_VIEWS.map((view) => <a key={view} className={currentView === view ? 'active' : ''} aria-current={currentView === view ? 'page' : undefined} href={routePath({ page: 'repositories', repositoryId: selectedRepository.id, view })} onClick={(event) => handleNavigation(event, { page: 'repositories', repositoryId: selectedRepository.id, view })}>{detailViewLabel(view, report)}</a>)}</nav>{advancedOpen && <section className="section-card advanced-info"><div><span className="eyebrow">Advanced repository info</span><h2>Identity and policy</h2></div><div className="info-grid"><span><strong>Provider ID</strong><code>{selectedRepository.provider_repository_id}</code></span><span><strong>Visibility</strong>{selectedRepository.private ? 'Private' : 'Public'}</span><span><strong>Auto scan</strong>{selectedRepository.policy.auto_scan ? 'Enabled' : 'Disabled'}</span><span><strong>Minimum health</strong>{selectedRepository.policy.min_health_score}</span><span><strong>Latest revision</strong><code>{shortRevision(selectedRepository.latest_revision)}</code></span><span><strong>Catalog sync</strong>{formatDate(selectedRepository.last_synced_at)}</span></div></section>}{selectedDetailsLoading ? <section className="workspace-loading" role="status"><span /><span /><span /></section> : report ? <section className="detail-content">{currentView === 'overview' && <OverviewView report={report} comparison={comparison} />}{currentView === 'architecture' && <ArchitectureView report={report} />}{currentView === 'hotspots' && <HotspotsView report={report} />}{currentView === 'dependencies' && <DependenciesView report={report} />}{currentView === 'findings' &&Line truncated
|
||||
}
|
||||
|
||||
const renderActivityPage = () => { const activity = [...repositories].filter((repository) => repository.last_scan_at || repository.state === 'scanning' || repository.state === 'queued').sort((left, right) => (Date.parse(right.last_scan_at ?? '') || 0) - (Date.parse(left.last_scan_at ?? '') || 0)); return <section className="page-content activity-page" aria-labelledby="activity-title"><div className="page-heading"><div><span className="eyebrow">Operational timeline</span><h1 id="activity-title">Activity</h1><p className="muted">A visual event feed derived from actual catalog state and persisted scan activity.</p></div></div><div className="summary-strip"><div><span>Catalog</span><strong>{repositories.length}</strong></div><div><span>Current</span><strong>{repositories.filter((repository) => repository.state === 'current').length}</strong></div><div><span>Needs attention</span><strong>{repositories.filter((repository) => repositoryReadiness(repository) !== 'ready').length}</strong></div><div><span>Latest scan</span><strong>{activity[0] ? formatRelativeDate(activity[0].last_scan_at) : '—'}</strong></div></div><section className="activity-timeline section-card"><div className="section-heading"><div><span className="eyebrow">Latest changes</span><h2>Repository activity</h2></div><span className="section-note">Each event opens its repository workspace.</span></div>{activity.length ? activity.slice(0, 30).map((repository) => <button type="button" className="timeline-event" key={repository.id} onClick={() => selectRepository(repository)}><span className={`timeline-rail ${repositoryReadiness(repository)}`} /><RepositoryVisual repository={repository} compact /><span><strong>{repository.full_name}</strong><small>{repository.state === 'scanning' || repository.state === 'queued' ? `${stateLabel(repository.state)} · ${repository.default_branch}` : `Scan completed ${formatRelativeDate(repository.last_scan_at)} · ${repository.default_branch}`}</small></span><b>{repository.last_scan_health ?? '—'}</b></button>) : <div className="empty-state compact-empty"><BrandMark size={34} label="" /><h2>No activity yet</h2><p className="muted">Scan a repository to start building its operational timeline.</p></div>}</section></section> }
|
||||
const renderQueuePage = () => { const queue = repositories.filter((repository) => repository.active_job_id || repository.state === 'queued' || repository.state === 'scanning'); return <section className="page-content queue-page" aria-labelledby="queue-title"><div className="page-heading"><div><span className="eyebrow">Durable background work</span><h1 id="queue-title">Scan queue</h1><p className="muted">Exact revisions are retained while work moves through source acquisition, analysis and scoring.</p></div></div><div className="summary-strip"><div><span>Waiting</span><strong>{queue.filter((repository) => repository.state === 'queued').length}</strong></div><div><span>Active</span><strong>{queue.filter((repository) => repository.state === 'scanning' || repository.active_job_id).length}</strong></div><div><span>Completed</span><strong>{meta?.completed_jobs ?? '—'}</strong></div><div><span>Failed</span><strong>{meta?.failed_jobs ?? '—'}</strong></div></div><section className="queue-workspace section-card"><div className="section-heading"><div><span className="eyebrow">Live queue</span><h2>{queue.length ? 'Scans in progress' : 'Queue is clear'}</h2></div><span className="section-note">No misleading progress percentage is shown before the worker reports one.</span></div>{queue.length ? <div className="queue-list">{queue.map((repository) => <button type="button" className="queue-row" key={repository.id} onClick={() => selectRepository(repository)}><RepositoryVisual repository={repository} compact /><span><strong>{repository.full_name}</strong><small>{repository.default_branch} · {repository.active_job_id ? 'worker active' : 'waiting for worker'}</small></span><span className={`status-label status-${statusClass(repository.state)}`}><i aria-hidden="true" />{stateLabel(repository.state)}</span></button>)}</div> : <div className="queue-empty"><BrandMark size={48} label="" /><div><span className="eyebrow">All clear</span><h2>No scans are waiting</h2><p className="muted">The worker is healthy and the portfolio is up to date. Start a scan from a repository workspace whenever a fresh revision needs analysis.</p><a className="button-link" href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Browse repositories</a></div></div>}</section></section> }
|
||||
const renderSettingsPage = () => <section className="page-content settings-page" aria-labelledby="settings-title"><div className="page-heading"><div><span className="eyebrow">Workspace configuration</span><h1 id="settings-title">Settings</h1><p className="muted">Connection, scanning and local preferences are separated into concise operational sections.</p></div></div><div className="settings-sections"><section className="settings-section"><div><span className="eyebrow">Connections</span><h2>Gitea catalog</h2><p>Provider-backed repository discovery and exact revision acquisition.</p></div><dl><div><dt>Status</dt><dd><span className={`status-label status-${meta?.gitea.configured ? 'current' : 'failed'}`}><i aria-hidden="true" />{meta?.gitea.configured ? 'Configured' : 'Not configured'}</span></dd></div><div><dt>Repositories</dt><dd>{meta?.repository_count ?? repositories.length}</dd></div><div><dt>Last sync</dt><dd>{meta?.gitea.last_sync && typeof meta.gitea.last_sync === 'object' && 'synced_at' in meta.gitea.last_sync ? formatDate(String(meta.gitea.last_sync.synced_at)) : 'Not available'}</dd></div></dl><button className="ghost" type="button" onClick={() => void syncCatalog()} disabled={catalogBusy}>{catalogBusy ? 'Syncing…' : 'Sync catalog now'}</button></section><section className="settings-section"><div><span className="eyebrow">Scanning & analysis</span><h2>Safe source controls</h2><p>The normal repository flow stays Gitea-native. The advanced path remains available only for mounted local sources.</p></div><dl><div><dt>Scan root</dt><dd>{meta?.scan_root_restricted ? 'Restricted' : 'Configured by server'}</dd></div><div><dt>Concurrency</dt><dd>{meta?.max_concurrent_scans ?? '—'} worker</dd></div><div><dt>Automation</dt><dd>Scheduled discovery enabled</dd></div></dl><form className="local-form" onSubmit={scanLocal}><label><span>Advanced repository path</span><input aria-label="Repository path" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/scan/my-project" required /></label><button disabled={busy}>{busy ? 'Analyzing…' : 'Analyze local source'}</button></form>{standaloneReport && <div className="local-result" role="status"><strong>{standaloneReport.repository_name}</strong><span>{standaloneReport.health_score}/100 · {standaloneReport.release_readiness} · {formatDate(standaloneReport.scanned_at)}</span></div>}</section><section className="settings-section appearance"><div><span className="eyebrow">Appearance</span><h2>Personal workspace preferences</h2><p>Dark signal topology is the primary theme. View modes and navigation preferences are stored locally in this browser.</p></div><dl><div><dt>Explorer view</dt><dd><div className="view-toggle"><button className={catalogView === 'table' ? 'active' : ''} type="button" onClick={() => setCatalogView('table')}>Table</button><button className={catalogView === 'grid' ? 'active' : ''} type="button" onClick={() => setCatalogView('grid')}>Visual grid</button></div></dd></div><div><dt>Pinned repositories</dt><dd>{pinnedIds.length}</dd></div><div><dt>Recent repositories</dt><dd>{recentIds.length}</dd></div></dl></section></div></section>
|
||||
|
||||
return <main className="app-shell">{renderTopbar()}{error && !route.repositoryId && <div className="error-banner global-banner" role="alert">{error}</div>}{notice && !route.repositoryId && <div className="notice-banner global-banner" role="status">{notice}</div>}{route.page === 'dashboard' && <DashboardView repositories={repositories} pinnedIds={pinnedIds} onOpenRepository={selectRepository} onExploreReadiness={exploreReadiness} onOpenExplorer={() => navigate({ page: 'repositories' })} />}{route.page === 'activity' && renderActivityPage()}{route.page === 'queue' && renderQueuePage()}{route.page === 'settings' && renderSettingsPage()}{route.page === 'repositories' && (route.repositoryId ? renderDetailPage() : renderCatalogPage())}{switcherOpen && <div className="modal-backdrop" role="presentation" onMouseDown={() => setSwitcherOpen(false)}><section className="switcher-modal" role="dialog" aria-modal="true" aria-labelledby="switcher-title" onMouseDown={(event) => event.stopPropagation()}><div className="switcher-heading"><div><span className="eyebrow">Repository navigation</span><h2 id="switcher-title">Quick switcher</h2></div><button className="icon-button" type="button" onClick={() => setSwitcherOpen(false)} aria-label="Close quick switcher">×</button></div><div className="command-row"><button type="button" onClick={() => { navigate({ page: 'dashboard' }); setSwitcherOpen(false) }}>Open dashboard</button><button type="button" onClick={() => { navigate({ page: 'queue' }); setSwitcherOpen(false) }}>Open scan queue</button><button type="button" onClick={() => void syncCatalog()} disabled={catalogBusy}>Sync Gitea</button></div><label className="switcher-search"><span className="sr-only">Search repositories</span><input ref={switcherInputRef} value={switcherQuery} onChange={(event) => { setSwitcherQuery(event.target.value); setSwitcherIndex(0) }} placeholder="Search repositories…" /><kbd>Esc</kbd></label>{switcherRepositories.length ? <div className="switcher-results" role="listbox" aria-label="Repositories">{switcherRepositories.map((repository, index) => <button className={index === switcherIndex ? 'switcher-item active' : 'switcher-item'} type="button" role="option" aria-selected={index === switcherIndex} key={repository.id} onMouseEnter={() => setSwitcherIndex(index)} onClick={() => { selectRepository(repository); setSwitcherOpen(false) }}><RepositoryVisual repository={repository} compact /><span><strong>{repository.full_name}</strong><small>{repository.owner} · {repository.default_branch} · {repository.last_scan_health ?? 'not scanned'}</small></span><span className={`readiness-label readiness-${repositoryReadiness(repository)}`}><i aria-hidden="true" />{readinessLabel(repositoryReadiness(repository))}</span></button>)}</div> : <div className="empty-state compact-empty"><h2>No repository matches</h2><p className="muted">Try the owner, repository name or branch.</p></div>}<div className="switcher-footer"><span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span><span><kbd>Enter</kbd> Open</span><span><kbd>Esc</kbd> Close</span></div></section></div>}</main>
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ScanReport } from './api'
|
||||
import { FindingsView } from './components/FindingsView'
|
||||
|
||||
const report: ScanReport = {
|
||||
id: 'scan-1',
|
||||
repository_path: '/scan/repopulse',
|
||||
repository_name: 'RepoPulse',
|
||||
source: {
|
||||
origin: 'gitea',
|
||||
provider: 'gitea',
|
||||
repository_id: '64',
|
||||
repository_full_name: 'Jens/RepoPulse',
|
||||
branch: 'main',
|
||||
requested_revision: 'revision-1',
|
||||
actual_revision: 'revision-1',
|
||||
trigger: 'scheduled',
|
||||
},
|
||||
policy: {
|
||||
auto_scan: true,
|
||||
min_health_score: 70,
|
||||
max_blockers: 0,
|
||||
require_tests: true,
|
||||
require_ci: true,
|
||||
suppressions: [],
|
||||
},
|
||||
scanned_at: '2026-09-12T20:00:00Z',
|
||||
duration_ms: 1200,
|
||||
metrics: {
|
||||
files: 2,
|
||||
source_files: 2,
|
||||
lines_of_code: 20,
|
||||
test_files: 1,
|
||||
todo: 0,
|
||||
fixme: 0,
|
||||
hack: 0,
|
||||
xxx: 0,
|
||||
dependency_manifests: 1,
|
||||
declared_dependencies: 1,
|
||||
large_files: 0,
|
||||
large_source_files: 0,
|
||||
},
|
||||
languages: { TypeScript: 20 },
|
||||
category_scores: { 'Security basics': 20, Maintainability: 80 },
|
||||
health_score: 70,
|
||||
release_readiness: 'BLOCKED',
|
||||
findings: [
|
||||
{
|
||||
code: 'security.secret-assignment',
|
||||
category: 'Security basics',
|
||||
severity: 'blocker',
|
||||
message: 'Potential credential assignment detected; value intentionally redacted',
|
||||
path: 'backend/app/main.py',
|
||||
line: 245,
|
||||
deduction: 20,
|
||||
fingerprint: 'fingerprint-1',
|
||||
suppressed: false,
|
||||
},
|
||||
{
|
||||
code: 'maintainability.long-function',
|
||||
category: 'Maintainability',
|
||||
severity: 'warning',
|
||||
message: 'Function is longer than the configured threshold',
|
||||
path: 'backend/app/signals.py',
|
||||
line: 111,
|
||||
deduction: 4,
|
||||
fingerprint: 'fingerprint-2',
|
||||
suppressed: false,
|
||||
},
|
||||
],
|
||||
tests: { frameworks: ['pytest'], coverage_artifacts: [], ci_workflows: [], test_source_ratio: null },
|
||||
git: { available: true, commit_count: 1, contributor_count: 1, latest_commit_at: null, tracked_files: 2, renamed_files: [] },
|
||||
dependencies: { manifests: ['package.json'], lockfiles: ['package-lock.json'], declared_dependencies: 1, unbounded_dependencies: 0, unpinned_dependencies: 0, duplicate_dependencies: [] },
|
||||
architecture: { nodes: [], edges: [], clusters: {}, truncated: false, total_nodes: 0, total_edges: 0 },
|
||||
hotspots: [],
|
||||
release_gate: null,
|
||||
}
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('FindingsView', () => {
|
||||
it('keeps findings scanable while exposing expandable rule details', () => {
|
||||
const { container } = render(<FindingsView report={report} />)
|
||||
const firstFinding = container.querySelector('details.finding') as HTMLDetailsElement
|
||||
|
||||
expect(firstFinding.open).toBe(false)
|
||||
expect(screen.getByText('Potential credential assignment detected; value intentionally redacted')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByText('Potential credential assignment detected; value intentionally redacted'))
|
||||
|
||||
expect(firstFinding.open).toBe(true)
|
||||
expect(screen.getByText('−20 points')).toBeTruthy()
|
||||
expect(screen.getByText('backend/app/main.py:245')).toBeTruthy()
|
||||
expect(screen.getByText('security.secret-assignment')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('filters by severity and reports an actionable empty state', () => {
|
||||
render(<FindingsView report={report} />)
|
||||
const severity = screen.getByRole('combobox', { name: 'Filter findings by severity' })
|
||||
const query = screen.getByRole('textbox', { name: 'Filter findings' })
|
||||
|
||||
fireEvent.change(severity, { target: { value: 'warning' } })
|
||||
expect(screen.getByText(/Showing 1 of 2/)).toBeTruthy()
|
||||
|
||||
fireEvent.change(query, { target: { value: 'does-not-exist' } })
|
||||
expect(screen.getByText('No findings match the current filters.')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { OverviewView } from './components/OverviewView'
|
||||
import { testComparison, testReport } from './test-fixtures'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('OverviewView', () => {
|
||||
it('prioritizes readiness and surfaces context for a low-signal repository', () => {
|
||||
const report = testReport({
|
||||
metrics: {
|
||||
files: 0,
|
||||
source_files: 0,
|
||||
lines_of_code: 0,
|
||||
test_files: 0,
|
||||
todo: 0,
|
||||
fixme: 0,
|
||||
hack: 0,
|
||||
xxx: 0,
|
||||
dependency_manifests: 0,
|
||||
declared_dependencies: 0,
|
||||
large_files: 0,
|
||||
large_source_files: 0,
|
||||
},
|
||||
dependencies: {
|
||||
manifests: [],
|
||||
lockfiles: [],
|
||||
declared_dependencies: 0,
|
||||
unbounded_dependencies: 0,
|
||||
unpinned_dependencies: 0,
|
||||
duplicate_dependencies: [],
|
||||
},
|
||||
languages: {},
|
||||
findings: [],
|
||||
release_readiness: 'READY',
|
||||
release_gate: null,
|
||||
architecture: { nodes: [], edges: [], clusters: {}, truncated: false, total_nodes: 0, total_edges: 0 },
|
||||
hotspots: [],
|
||||
})
|
||||
|
||||
const { container } = render(<OverviewView report={report} comparison={testComparison} />)
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('little or no analyzable source code')
|
||||
expect(screen.getAllByText('READY').length).toBeGreaterThan(0)
|
||||
expect(container.querySelector('.metric-grid.low-signal')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from './api'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('RepoPulse API client', () => {
|
||||
it('submits a local repository scan request', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'scan-1' }), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await api.scan('/scan/project')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/v1/scans',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: '/scan/project' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes repository paths for trend requests', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await api.trend('/scan/Project With Space')
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/trend?path=%2Fscan%2FProject%20With%20Space&limit=50')
|
||||
})
|
||||
|
||||
it('uses the comparison endpoint for persisted scan deltas', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
current_scan_id: 'scan-2',
|
||||
previous_scan_id: 'scan-1',
|
||||
health_score_delta: 4,
|
||||
category_score_deltas: {},
|
||||
metric_deltas: {},
|
||||
added_findings: [],
|
||||
resolved_findings: [],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await api.comparison('scan-2')
|
||||
|
||||
expect(result.health_score_delta).toBe(4)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/scans/scan-2/comparison')
|
||||
})
|
||||
|
||||
it('uses repository identity and queues a revision-aware scan without a path', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'job-1', status: 'queued' }), {
|
||||
status: 202,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await api.enqueueRepositoryScan('gitea:64')
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/repositories/gitea%3A64/scans')
|
||||
expect(fetchMock.mock.calls[0][1]).toEqual(expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ force: false }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,386 @@
|
||||
export type Severity = 'info' | 'warning' | 'blocker'
|
||||
export type GateState = 'pass' | 'warn' | 'block'
|
||||
export type SourceOrigin = 'local' | 'gitea'
|
||||
export type ScanTrigger = 'manual' | 'scheduled' | 'webhook' | 'local'
|
||||
export type ScanJobStatus = 'queued' | 'acquiring_source' | 'scanning' | 'analyzing' | 'persisting' | 'completed' | 'failed' | 'cancelled' | 'superseded'
|
||||
export type RepositoryState = 'never_scanned' | 'current' | 'stale' | 'queued' | 'scanning' | 'failed' | 'unavailable' | 'archived'
|
||||
|
||||
export type Finding = {
|
||||
code: string
|
||||
category: string
|
||||
severity: Severity
|
||||
message: string
|
||||
path: string | null
|
||||
line: number | null
|
||||
deduction: number
|
||||
fingerprint: string | null
|
||||
suppressed: boolean
|
||||
guidance?: FindingGuidance | null
|
||||
}
|
||||
|
||||
export type FindingGuidance = {
|
||||
remediation_class: string
|
||||
confidence: string
|
||||
why: string
|
||||
recommended_action: string
|
||||
priority: number
|
||||
priority_rationale: string
|
||||
}
|
||||
|
||||
export type RepositoryMetrics = {
|
||||
files: number
|
||||
source_files: number
|
||||
lines_of_code: number
|
||||
test_files: number
|
||||
todo: number
|
||||
fixme: number
|
||||
hack: number
|
||||
xxx: number
|
||||
dependency_manifests: number
|
||||
declared_dependencies: number
|
||||
large_files: number
|
||||
large_source_files: number
|
||||
}
|
||||
|
||||
export type TestSignals = {
|
||||
frameworks: string[]
|
||||
coverage_artifacts: string[]
|
||||
ci_workflows: string[]
|
||||
ci_validation_workflows?: string[]
|
||||
ci_build_workflows?: string[]
|
||||
ci_deploy_only_workflows?: string[]
|
||||
coverage_evidence?: string[]
|
||||
test_source_ratio: number | null
|
||||
}
|
||||
|
||||
export type GitSignals = {
|
||||
available: boolean
|
||||
commit_count: number | null
|
||||
contributor_count: number | null
|
||||
latest_commit_at: string | null
|
||||
tracked_files: number | null
|
||||
renamed_files: string[]
|
||||
}
|
||||
|
||||
export type DependencySignals = {
|
||||
manifests: string[]
|
||||
lockfiles: string[]
|
||||
declared_dependencies: number
|
||||
unbounded_dependencies: number
|
||||
unpinned_dependencies: number
|
||||
duplicate_dependencies: string[]
|
||||
included_requirements?: string[]
|
||||
}
|
||||
|
||||
export type ArchitectureNode = {
|
||||
id: string
|
||||
label: string
|
||||
path: string
|
||||
language: string
|
||||
inbound: number
|
||||
outbound: number
|
||||
visible_inbound: number
|
||||
visible_outbound: number
|
||||
hotspot_score: number
|
||||
}
|
||||
|
||||
export type ArchitectureEdge = {
|
||||
source: string
|
||||
target: string
|
||||
kind: string
|
||||
}
|
||||
|
||||
export type ArchitectureGraph = {
|
||||
nodes: ArchitectureNode[]
|
||||
edges: ArchitectureEdge[]
|
||||
clusters: Record<string, number>
|
||||
truncated: boolean
|
||||
total_nodes: number
|
||||
total_edges: number
|
||||
}
|
||||
|
||||
export type Hotspot = {
|
||||
path: string
|
||||
language: string
|
||||
lines_of_code: number
|
||||
debt_markers: number
|
||||
churn: number
|
||||
inbound: number
|
||||
outbound: number
|
||||
score: number
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
export type GateCheck = {
|
||||
key: string
|
||||
label: string
|
||||
state: GateState
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type ReleaseGate = {
|
||||
status: string
|
||||
checks: GateCheck[]
|
||||
}
|
||||
|
||||
export type RepositoryPolicy = {
|
||||
auto_scan: boolean
|
||||
min_health_score: number
|
||||
max_blockers: number
|
||||
require_tests: boolean
|
||||
require_ci: boolean
|
||||
suppressions: string[]
|
||||
}
|
||||
|
||||
export type RemediationSignal = {
|
||||
code: string
|
||||
category: string
|
||||
severity: Severity
|
||||
message: string
|
||||
count: number
|
||||
guidance?: FindingGuidance | null
|
||||
}
|
||||
|
||||
export type ScanLimits = {
|
||||
max_files: number
|
||||
max_text_bytes_per_file: number
|
||||
max_total_text_bytes?: number
|
||||
max_findings: number
|
||||
max_secret_findings: number
|
||||
}
|
||||
|
||||
export type ScanCoverage = {
|
||||
limits: ScanLimits
|
||||
discovered_files: number
|
||||
scanned_files: number
|
||||
skipped_files_by_budget: number
|
||||
skipped_files_by_size: number
|
||||
skipped_files_by_text_budget?: number
|
||||
text_bytes_scanned: number
|
||||
truncated: boolean
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
export type ScanSource = {
|
||||
origin: SourceOrigin
|
||||
provider: string | null
|
||||
repository_id: string | null
|
||||
repository_full_name: string | null
|
||||
branch: string | null
|
||||
requested_revision: string | null
|
||||
actual_revision: string | null
|
||||
trigger: ScanTrigger
|
||||
}
|
||||
|
||||
export type ScanReport = {
|
||||
id: string
|
||||
repository_path: string
|
||||
repository_name: string
|
||||
source: ScanSource
|
||||
policy: RepositoryPolicy
|
||||
scanned_at: string
|
||||
duration_ms: number
|
||||
metrics: RepositoryMetrics
|
||||
languages: Record<string, number>
|
||||
category_scores: Record<string, number>
|
||||
health_score: number
|
||||
release_readiness: string
|
||||
findings: Finding[]
|
||||
tests: TestSignals
|
||||
git: GitSignals
|
||||
dependencies: DependencySignals
|
||||
architecture: ArchitectureGraph
|
||||
hotspots: Hotspot[]
|
||||
release_gate: ReleaseGate | null
|
||||
coverage?: ScanCoverage | null
|
||||
compacted?: boolean
|
||||
}
|
||||
|
||||
export type RepositorySummary = {
|
||||
id: string
|
||||
provider: string
|
||||
provider_repository_id: string
|
||||
owner: string
|
||||
name: string
|
||||
full_name: string
|
||||
description: string | null
|
||||
html_url: string | null
|
||||
clone_url: string | null
|
||||
default_branch: string
|
||||
private: boolean
|
||||
archived: boolean
|
||||
available: boolean
|
||||
latest_revision: string | null
|
||||
latest_revision_at: string | null
|
||||
last_synced_at: string | null
|
||||
sync_error: string | null
|
||||
state: RepositoryState
|
||||
stale: boolean
|
||||
active_job_id: string | null
|
||||
last_scan_id: string | null
|
||||
last_scan_at: string | null
|
||||
last_scan_health: number | null
|
||||
last_scan_readiness: string | null
|
||||
last_scan_revision: string | null
|
||||
last_scan_blockers: number
|
||||
last_scan_warnings: number
|
||||
last_scan_primary_signal: RemediationSignal | null
|
||||
last_scan_health_delta: number | null
|
||||
last_scan_added_findings: number
|
||||
last_scan_resolved_findings: number
|
||||
last_scan_priority?: number
|
||||
last_scan_priority_rationale?: string | null
|
||||
policy: RepositoryPolicy
|
||||
}
|
||||
|
||||
export type RepositoryDetail = RepositorySummary & { latest_scan: ScanReport | null }
|
||||
|
||||
export type AppMeta = {
|
||||
name: string
|
||||
version: string
|
||||
stage: string
|
||||
scan_root_restricted: boolean
|
||||
scan_root: string | null
|
||||
max_concurrent_scans: number
|
||||
repository_count: number
|
||||
repository_states: Record<string, number>
|
||||
current_repositories: number
|
||||
stale_repositories: number
|
||||
queue_depth: number
|
||||
active_jobs: number
|
||||
completed_jobs: number
|
||||
failed_jobs: number
|
||||
worker_heartbeat: string | null
|
||||
gitea: {
|
||||
configured: boolean
|
||||
url: string | null
|
||||
last_sync: unknown
|
||||
}
|
||||
storage?: {
|
||||
database_bytes: number
|
||||
journal_bytes: number
|
||||
mirror_count: number
|
||||
mirror_cache_bytes: number
|
||||
worktree_count: number
|
||||
scan_count: number
|
||||
compacted_scan_count: number
|
||||
oldest_scan_at: string | null
|
||||
newest_scan_at: string | null
|
||||
history_per_repository: number
|
||||
full_reports_per_repository: number
|
||||
report_retention_days: number
|
||||
last_schema_migration_backup?: string | null
|
||||
}
|
||||
database_recovery?: Record<string, unknown>
|
||||
mutation_authentication?: 'operator_token' | 'perimeter_required'
|
||||
}
|
||||
|
||||
export type ScanJob = {
|
||||
id: string
|
||||
repository_id: string
|
||||
status: ScanJobStatus
|
||||
trigger: ScanTrigger
|
||||
requested_revision: string | null
|
||||
actual_revision: string | null
|
||||
phase: string
|
||||
progress: number
|
||||
message: string | null
|
||||
created_at: string
|
||||
started_at: string | null
|
||||
updated_at: string
|
||||
finished_at: string | null
|
||||
duration_ms: number | null
|
||||
scan_id: string | null
|
||||
error_code: string | null
|
||||
error_detail: string | null
|
||||
attempt: number
|
||||
deduplicated: boolean
|
||||
}
|
||||
|
||||
export type ScanSummary = {
|
||||
id: string
|
||||
repository_id: string
|
||||
scanned_at: string
|
||||
branch: string | null
|
||||
requested_revision: string | null
|
||||
actual_revision: string | null
|
||||
trigger: ScanTrigger
|
||||
health_score: number
|
||||
release_readiness: string
|
||||
findings: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
export type RepositorySyncResult = {
|
||||
provider: string
|
||||
discovered: number
|
||||
created: number
|
||||
updated: number
|
||||
unavailable: number
|
||||
errors: string[]
|
||||
partial?: boolean
|
||||
branch_failures?: number
|
||||
listing_complete?: boolean
|
||||
synced_at: string
|
||||
}
|
||||
|
||||
export type ScanComparison = {
|
||||
current_scan_id: string
|
||||
previous_scan_id: string | null
|
||||
health_score_delta: number | null
|
||||
category_score_deltas: Record<string, number>
|
||||
metric_deltas: Record<string, number>
|
||||
added_findings: Finding[]
|
||||
resolved_findings: Finding[]
|
||||
}
|
||||
|
||||
export type TrendPoint = {
|
||||
scan_id: string
|
||||
scanned_at: string
|
||||
health_score: number
|
||||
findings: number
|
||||
blockers: number
|
||||
warnings: number
|
||||
debt_markers: number
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
...init,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
throw new Error(payload?.detail || `Request failed (${response.status})`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
const repositoryPath = (id: string) => encodeURIComponent(id)
|
||||
|
||||
export const api = {
|
||||
scan: (path: string) => request<ScanReport>('/api/v1/scans', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
recent: () => request<ScanReport[]>('/api/v1/scans?limit=20'),
|
||||
meta: () => request<AppMeta>('/api/v1/meta'),
|
||||
repositories: () => request<RepositorySummary[]>('/api/v1/repositories?limit=1000'),
|
||||
repository: (id: string) => request<RepositoryDetail>(`/api/v1/repositories/${repositoryPath(id)}`),
|
||||
repositoryScans: (id: string) => request<ScanSummary[]>(`/api/v1/repositories/${repositoryPath(id)}/scans?limit=200`),
|
||||
repositoryTrend: (id: string) => request<TrendPoint[]>(`/api/v1/repositories/${repositoryPath(id)}/trend?limit=100`),
|
||||
syncRepositories: () => request<RepositorySyncResult>('/api/v1/repositories/sync', { method: 'POST' }),
|
||||
enqueueRepositoryScan: (id: string, force = false) => request<ScanJob>(`/api/v1/repositories/${repositoryPath(id)}/scans`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ force }),
|
||||
}),
|
||||
scanJob: (id: string) => request<ScanJob>(`/api/v1/scan-jobs/${encodeURIComponent(id)}`),
|
||||
repositoryScan: (repositoryId: string, scanId: string) => request<ScanReport>(`/api/v1/repositories/${repositoryPath(repositoryId)}/scans/${encodeURIComponent(scanId)}`),
|
||||
comparison: (scanId: string) => request<ScanComparison>(`/api/v1/scans/${encodeURIComponent(scanId)}/comparison`),
|
||||
trend: (path: string) => request<TrendPoint[]>(`/api/v1/trend?path=${encodeURIComponent(path)}&limit=50`),
|
||||
exportReport: async (scanId: string, format: 'html' | 'json') => {
|
||||
const response = await fetch(`/api/v1/scans/${encodeURIComponent(scanId)}/export?format=${format}`)
|
||||
if (!response.ok) throw new Error(`Export failed (${response.status})`)
|
||||
return response.text()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { filterRepositories, orderSwitcherRepositories, paginateRepositories, sortRepositories } from './catalog'
|
||||
import type { RepositorySummary } from './api'
|
||||
|
||||
function repository(index: number): RepositorySummary {
|
||||
return {
|
||||
id: `gitea:${index}`,
|
||||
provider: 'gitea',
|
||||
provider_repository_id: String(index),
|
||||
owner: index % 2 ? 'Jens' : 'Team',
|
||||
name: `repo-${String(index).padStart(3, '0')}`,
|
||||
full_name: `${index % 2 ? 'Jens' : 'Team'}/repo-${String(index).padStart(3, '0')}`,
|
||||
description: null,
|
||||
html_url: null,
|
||||
clone_url: null,
|
||||
default_branch: index % 3 ? 'main' : 'develop',
|
||||
private: index % 4 === 0,
|
||||
archived: index % 17 === 0,
|
||||
available: true,
|
||||
latest_revision: `revision-${index}`,
|
||||
latest_revision_at: '2026-09-12T10:00:00Z',
|
||||
last_synced_at: '2026-09-12T10:00:00Z',
|
||||
sync_error: null,
|
||||
state: index % 11 === 0 ? 'never_scanned' : 'current',
|
||||
stale: false,
|
||||
active_job_id: null,
|
||||
last_scan_id: index % 11 === 0 ? null : `scan-${index}`,
|
||||
last_scan_at: index % 11 === 0 ? null : '2026-09-12T10:00:00Z',
|
||||
last_scan_health: index % 11 === 0 ? null : 80 + (index % 20),
|
||||
last_scan_readiness: index % 11 === 0 ? null : 'READY',
|
||||
last_scan_revision: `revision-${index}`,
|
||||
last_scan_blockers: 0,
|
||||
last_scan_warnings: 0,
|
||||
last_scan_primary_signal: null,
|
||||
last_scan_health_delta: null,
|
||||
last_scan_added_findings: 0,
|
||||
last_scan_resolved_findings: 0,
|
||||
policy: { auto_scan: true, min_health_score: 70, max_blockers: 0, require_tests: false, require_ci: false, suppressions: [] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('repository catalog helpers', () => {
|
||||
it('filters by owner, branch, visibility and readiness', () => {
|
||||
const repositories = Array.from({ length: 20 }, (_, index) => repository(index + 1))
|
||||
const result = filterRepositories(repositories, {
|
||||
query: 'repo-0',
|
||||
state: 'all',
|
||||
readiness: 'ready',
|
||||
owner: 'Jens',
|
||||
branch: 'main',
|
||||
visibility: 'public',
|
||||
archived: 'active',
|
||||
})
|
||||
|
||||
expect(result.every((item) => item.owner === 'Jens' && item.default_branch === 'main' && !item.private && !item.archived)).toBe(true)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('sorts deterministically and paginates large catalogs without rendering all rows', () => {
|
||||
const repositories = Array.from({ length: 500 }, (_, index) => repository(index + 1))
|
||||
const sorted = sortRepositories(repositories, { key: 'name', direction: 'asc' })
|
||||
const page = paginateRepositories(sorted, 7, 25)
|
||||
|
||||
expect(page.total).toBe(500)
|
||||
expect(page.pageCount).toBe(20)
|
||||
expect(page.page).toBe(7)
|
||||
expect(page.items).toHaveLength(25)
|
||||
expect(page.items[0].full_name).toBe('Jens/repo-301')
|
||||
})
|
||||
|
||||
it('puts pinned and recent repositories first in the quick switcher', () => {
|
||||
const repositories = [repository(1), repository(2), repository(3)]
|
||||
const result = orderSwitcherRepositories(repositories, ['gitea:3'], ['gitea:2'], 'repo')
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(['gitea:3', 'gitea:2', 'gitea:1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { RepositoryState, RepositorySummary } from './api'
|
||||
|
||||
export type Readiness = 'ready' | 'warnings' | 'blocked'
|
||||
|
||||
export type CatalogFilters = {
|
||||
query: string
|
||||
state: 'all' | RepositoryState
|
||||
readiness: 'all' | Readiness
|
||||
owner: string
|
||||
branch: string
|
||||
visibility: 'all' | 'private' | 'public'
|
||||
archived: 'all' | 'active' | 'archived'
|
||||
}
|
||||
|
||||
export type CatalogSortKey = 'name' | 'health' | 'last_scan' | 'state'
|
||||
|
||||
export type CatalogSort = {
|
||||
key: CatalogSortKey
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type CatalogPage = {
|
||||
items: RepositorySummary[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
pageCount: number
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||
|
||||
export function repositoryReadiness(repository: RepositorySummary): Readiness {
|
||||
if (!repository.available || repository.state === 'failed' || repository.state === 'unavailable') return 'blocked'
|
||||
if (!repository.last_scan_id || repository.state === 'never_scanned' || repository.last_scan_health == null) return 'warnings'
|
||||
const persistedReadiness = repository.last_scan_readiness?.toUpperCase() ?? ''
|
||||
if (persistedReadiness.includes('BLOCK')) return 'blocked'
|
||||
if (persistedReadiness.includes('WARNING')) return 'warnings'
|
||||
if (persistedReadiness === 'READY') return 'ready'
|
||||
if (repository.last_scan_health < 60) return 'blocked'
|
||||
if (repository.stale || repository.state === 'stale' || repository.state === 'queued' || repository.state === 'scanning' || repository.last_scan_health < 80) return 'warnings'
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
export function readinessLabel(readiness: Readiness) {
|
||||
if (readiness === 'ready') return 'Ready'
|
||||
if (readiness === 'warnings') return 'Warnings'
|
||||
return 'Blocked'
|
||||
}
|
||||
|
||||
export function stateLabel(state: RepositoryState) {
|
||||
if (state === 'scanning') return 'Scanning'
|
||||
if (state === 'queued') return 'Queued'
|
||||
if (state === 'never_scanned') return 'Never scanned'
|
||||
if (state === 'current') return 'Current'
|
||||
if (state === 'stale') return 'Stale'
|
||||
if (state === 'archived') return 'Archived'
|
||||
if (state === 'unavailable') return 'Unavailable'
|
||||
return 'Failed'
|
||||
}
|
||||
|
||||
export function filterRepositories(repositories: RepositorySummary[], filters: CatalogFilters) {
|
||||
const query = filters.query.trim().toLowerCase()
|
||||
return repositories.filter((repository) => {
|
||||
if (query && !`${repository.full_name} ${repository.owner} ${repository.name} ${repository.provider} ${repository.default_branch}`.toLowerCase().includes(query)) return false
|
||||
if (filters.state !== 'all' && repository.state !== filters.state) return false
|
||||
if (filters.readiness !== 'all' && repositoryReadiness(repository) !== filters.readiness) return false
|
||||
if (filters.owner !== 'all' && repository.owner !== filters.owner) return false
|
||||
if (filters.branch !== 'all' && repository.default_branch !== filters.branch) return false
|
||||
if (filters.visibility === 'private' && !repository.private) return false
|
||||
if (filters.visibility === 'public' && repository.private) return false
|
||||
if (filters.archived === 'active' && repository.archived) return false
|
||||
if (filters.archived === 'archived' && !repository.archived) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function sortRepositories(repositories: RepositorySummary[], sort: CatalogSort) {
|
||||
const sorted = [...repositories].sort((left, right) => {
|
||||
let comparison = 0
|
||||
if (sort.key === 'name') comparison = collator.compare(left.full_name, right.full_name)
|
||||
if (sort.key === 'health') comparison = (left.last_scan_health ?? -1) - (right.last_scan_health ?? -1)
|
||||
if (sort.key === 'last_scan') comparison = (Date.parse(left.last_scan_at ?? '') || 0) - (Date.parse(right.last_scan_at ?? '') || 0)
|
||||
if (sort.key === 'state') comparison = collator.compare(stateLabel(left.state), stateLabel(right.state))
|
||||
if (comparison === 0) comparison = collator.compare(left.full_name, right.full_name)
|
||||
return sort.direction === 'asc' ? comparison : -comparison
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
export function paginateRepositories(repositories: RepositorySummary[], page: number, pageSize: number): CatalogPage {
|
||||
const safePageSize = Math.max(1, pageSize)
|
||||
const pageCount = Math.max(1, Math.ceil(repositories.length / safePageSize))
|
||||
const safePage = Math.min(Math.max(1, page), pageCount)
|
||||
const start = (safePage - 1) * safePageSize
|
||||
return {
|
||||
items: repositories.slice(start, start + safePageSize),
|
||||
total: repositories.length,
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
pageCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function orderSwitcherRepositories(repositories: RepositorySummary[], pinnedIds: string[], recentIds: string[], query = '') {
|
||||
const needle = query.trim().toLowerCase()
|
||||
return repositories
|
||||
.filter((item) => !needle || `${item.full_name} ${item.owner} ${item.name} ${item.default_branch}`.toLowerCase().includes(needle))
|
||||
.sort((left, right) => {
|
||||
const leftPinned = pinnedIds.includes(left.id) ? 0 : 1
|
||||
const rightPinned = pinnedIds.includes(right.id) ? 0 : 1
|
||||
if (leftPinned !== rightPinned) return leftPinned - rightPinned
|
||||
const leftRecent = recentIds.indexOf(left.id)
|
||||
const rightRecent = recentIds.indexOf(right.id)
|
||||
if (leftRecent !== rightRecent) return (leftRecent < 0 ? 99 : leftRecent) - (rightRecent < 0 ? 99 : rightRecent)
|
||||
return left.full_name.localeCompare(right.full_name)
|
||||
})
|
||||
.slice(0, 50)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Background, Controls, MiniMap, ReactFlow, type Edge, type Node } from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
import type { ArchitectureNode, ScanReport } from '../api'
|
||||
|
||||
export function ArchitectureView({ report }: { report: ScanReport }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const visibleSource = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase()
|
||||
return report.architecture.nodes.filter((node) => !needle || `${node.label} ${node.path} ${node.language}`.toLocaleLowerCase().includes(needle))
|
||||
}, [query, report.architecture.nodes])
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, ArchitectureNode[]>()
|
||||
for (const node of visibleSource) {
|
||||
const cluster = node.path.includes('/') ? node.path.split('/')[0] : 'root'
|
||||
groups.set(cluster, [...(groups.get(cluster) ?? []), node])
|
||||
}
|
||||
return groups
|
||||
}, [visibleSource])
|
||||
const nodes = useMemo(() => {
|
||||
const result: Node[] = []
|
||||
Array.from(grouped.entries()).forEach(([cluster, items], clusterIndex) => {
|
||||
const columns = 8
|
||||
const clusterWidth = columns * 230 + 260
|
||||
const orderedItems = [...items].sort((left, right) => right.hotspot_score - left.hotspot_score || left.path.localeCompare(right.path))
|
||||
orderedItems.forEach((item, index) => result.push({
|
||||
id: item.id,
|
||||
position: { x: clusterIndex * clusterWidth + (index % columns) * 230, y: Math.floor(index / columns) * 150 },
|
||||
data: { label: `${item.label} · ${item.hotspot_score}` },
|
||||
className: item.id === selectedId ? 'arch-selected' : item.hotspot_score >= 70 ? 'arch-hot' : item.hotspot_score >= 40 ? 'arch-warm' : 'arch-calm',
|
||||
}))
|
||||
})
|
||||
return result
|
||||
}, [grouped, selectedId])
|
||||
const visible = new Set(nodes.map((node) => node.id))
|
||||
const edges: Edge[] = report.architecture.edges.filter((edge) => visible.has(edge.source) && visible.has(edge.target)).map((edge, index) => ({ id: `${edge.source}-${edge.target}-${index}`, source: edge.source, target: edge.target, animated: edge.source === selectedId || edge.target === selectedId }))
|
||||
const selected = visibleSource.find((node) => node.id === selectedId) ?? null
|
||||
|
||||
return <div className="view-stack"><section className="section-card architecture-summary"><div><span className="eyebrow">Architecture intelligence</span><h2>Local import and dependency topology</h2><p className="muted">Search, pan and zoom the bounded graph. Selecting a module reveals its local coupling and hotspot signals.</p></div><div className="architecture-stats"><div><strong>{nodes.length}<small>/{report.architecture.total_nodes || report.architecture.nodes.length}</small></strong><span>visible / total nodes</span></div><div><strong>{edges.length}<small>/{report.architecture.total_edges || report.architecture.edges.length}</small></strong><span>visible / total edges</span></div><div><strong>{grouped.size}</strong><span>clusters</span></div></div></section><section className="architecture-workspace section-card"><div className="architecture-toolbar"><label><span className="sr-only">Search architecture</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search module, path or language" /></label><span>{visibleSource.length} modules visible</span></div><div className="architecture-main"><div className="architecture-canvas">{nodes.length ? <ReactFlow nodes={nodes} edges={edges} fitView minZoom={0.1} onNodeClick={(_, node) => setSelectedId(node.id)} onPaneClick={() => setSelectedId(null)}><Background gap={20} /><MiniMap pannable zoomable /><Controls /></ReactFlow> : <div className="empty-inline">No modules match this architecture query.</div>}</div><aside className="architecture-inspector" aria-live="polite">{selected ? <><span className="eyebrow">Selected module</span><h3>{selected.label}</h3><code>{selected.path}</code><dl><div><dt>Language</dt><dd>{selected.language}</dd></div><div><dt>Hotspot score</dt><dd>{selected.hotspot_score}</dd></div><div><dt>Inbound edges</dt><dd>{selected.inbound}</dd></div><div><dt>Outbound edges</dt><dd>{selected.outbound}</dd></div></dl></> : <><span className="eyebrow">Module inspector</span><h3>Explore the topology</h3><p>Click a node to see its path, language and coupling. The graph stays bounded for a responsive workspace.</p></>}</aside></div></section>{report.architecture.truncated && <div className="notice-banner">The graph is intentionally bounded for responsiveness; hotspot analysis still considers all supported source files.</div>}</div>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
type BrandMarkProps = {
|
||||
size?: number
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RepoPulse's mark combines a bounded repository topology with a measured
|
||||
* signal. It is deliberately geometric so it stays legible at favicon size.
|
||||
*/
|
||||
export function BrandMark({ size = 32, label = 'RepoPulse', className = '' }: BrandMarkProps) {
|
||||
return (
|
||||
<svg className={`brand-svg ${className}`} width={size} height={size} viewBox="0 0 32 32" role={label ? 'img' : undefined} aria-label={label || undefined} xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="currentColor" opacity=".14" />
|
||||
<path d="M7 9.5h5.1l2.2 2.2H25" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M7 22.5h5.1l2.2-2.2H25" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="7" cy="9.5" r="2" fill="currentColor" />
|
||||
<circle cx="7" cy="22.5" r="2" fill="currentColor" />
|
||||
<circle cx="25" cy="16" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrandLockup({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<span className="brand-lockup-copy">
|
||||
<BrandMark size={compact ? 28 : 32} label="RepoPulse mark" />
|
||||
{!compact && <span><strong>RepoPulse</strong><small>repository intelligence</small></span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DashboardView } from './DashboardView'
|
||||
import { testRepository } from '../test-fixtures'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('DashboardView', () => {
|
||||
it('aggregates repository readiness and carries intelligence interactions into navigation', () => {
|
||||
const blocked = testRepository('gitea:10', 'Jens/Blocked', { last_scan_health: 58, last_scan_readiness: 'BLOCKED', last_scan_added_findings: 2, last_scan_priority: 800, last_scan_primary_signal: { code: 'dependency.no-lockfile', category: 'Dependencies', severity: 'blocker', message: 'No lockfile was retained for the declared dependencies.', count: 1, guidance: { remediation_class: 'dependency reproducibility', confidence: 'high', why: 'The workspace has no retained package-manager lockfile.', recommended_action: 'Commit the package-manager lockfile for a repeatable build.', priority: 800, priority_rationale: 'Blocker signal.' } } })
|
||||
const ready = testRepository('gitea:11', 'Jens/Ready', { last_scan_health: 96, last_scan_readiness: 'READY' })
|
||||
const warning = testRepository('gitea:12', 'Jens/Warning', { last_scan_health: 85, last_scan_readiness: 'READY WITH WARNINGS' })
|
||||
const onOpenRepository = vi.fn()
|
||||
const onExploreReadiness = vi.fn()
|
||||
|
||||
render(<DashboardView repositories={[blocked, ready, warning]} pinnedIds={[ready.id]} onOpenRepository={onOpenRepository} onExploreReadiness={onExploreReadiness} onOpenExplorer={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: '80/100 portfolio signal' })).toBeTruthy()
|
||||
expect(screen.getByText('Why attention is needed')).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: 'Remediation Center' })).toBeTruthy()
|
||||
expect(screen.getAllByText('No lockfile was retained for the declared dependencies.')).toHaveLength(2)
|
||||
expect(screen.getAllByText('Commit the package-manager lockfile for a repeatable build.')).toHaveLength(2)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open 1 blocked repositories in Explorer' }))
|
||||
expect(onExploreReadiness).toHaveBeenCalledWith('blocked')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open Jens\/Blocked/ }))
|
||||
expect(onOpenRepository).toHaveBeenCalledWith(blocked)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { RepositorySummary } from '../api'
|
||||
import { readinessLabel, repositoryReadiness, type Readiness } from '../catalog'
|
||||
import { PortfolioAtlas } from './PortfolioAtlas'
|
||||
import { RemediationCenter } from './RemediationCenter'
|
||||
import { RepositoryVisual } from './RepositoryVisual'
|
||||
|
||||
type DashboardViewProps = {
|
||||
repositories: RepositorySummary[]
|
||||
pinnedIds: string[]
|
||||
onOpenRepository: (repository: RepositorySummary) => void
|
||||
onExploreReadiness: (readiness: Readiness) => void
|
||||
onOpenExplorer: () => void
|
||||
}
|
||||
|
||||
function relativeTime(value: string | null) {
|
||||
if (!value) return 'Not scanned'
|
||||
const difference = Date.now() - Date.parse(value)
|
||||
if (Number.isNaN(difference)) return 'Unknown scan time'
|
||||
if (difference < 3_600_000) return `${Math.max(1, Math.floor(difference / 60_000))}m ago`
|
||||
if (difference < 86_400_000) return `${Math.floor(difference / 3_600_000)}h ago`
|
||||
return `${Math.floor(difference / 86_400_000)}d ago`
|
||||
}
|
||||
|
||||
function attentionReason(repository: RepositorySummary) {
|
||||
if (repository.state === 'scanning') return 'Analysis is in progress — wait for the retained result before triage.'
|
||||
if (repository.stale) return 'The retained scan is stale — refresh it before relying on the release signal.'
|
||||
if (!repository.last_scan_at) return 'No retained scan exists yet — establish an evidence baseline.'
|
||||
if (repository.last_scan_primary_signal) return repository.last_scan_primary_signal.message
|
||||
if (repositoryReadiness(repository) === 'blocked') return `Release readiness is blocked at ${repository.last_scan_health ?? '—'} health.`
|
||||
return `Warnings remain at ${repository.last_scan_health ?? '—'} health; review them before the next release.`
|
||||
}
|
||||
|
||||
function remediationPath(repository: RepositorySummary) {
|
||||
return repository.last_scan_primary_signal?.guidance?.recommended_action ?? 'Open the retained evidence and first safe action.'
|
||||
}
|
||||
|
||||
function changeContext(repository: RepositorySummary) {
|
||||
if (repository.last_scan_added_findings > 0) return `${repository.last_scan_added_findings} new signal${repository.last_scan_added_findings === 1 ? '' : 's'} since prior scan`
|
||||
if (repository.last_scan_resolved_findings > 0) return `${repository.last_scan_resolved_findings} resolved since prior scan`
|
||||
return repository.last_scan_health_delta == null ? 'baseline signal' : 'longstanding signal'
|
||||
}
|
||||
|
||||
function triagePriority(repository: RepositorySummary) {
|
||||
return repository.last_scan_priority ?? 0
|
||||
}
|
||||
|
||||
function readinessDescription(readiness: Readiness) {
|
||||
if (readiness === 'blocked') return 'needs intervention'
|
||||
if (readiness === 'warnings') return 'needs review'
|
||||
return 'ready to ship'
|
||||
}
|
||||
|
||||
export function DashboardView({ repositories, pinnedIds, onOpenRepository, onExploreReadiness, onOpenExplorer }: DashboardViewProps) {
|
||||
const portfolio = repositories.filter((repository) => !repository.archived)
|
||||
const counts = portfolio.reduce<Record<Readiness, number>>((result, repository) => {
|
||||
result[repositoryReadiness(repository)] += 1
|
||||
return result
|
||||
}, { ready: 0, warnings: 0, blocked: 0 })
|
||||
const scored = portfolio.filter((repository) => repository.last_scan_health != null)
|
||||
const averageHealth = scored.length ? Math.round(scored.reduce((sum, repository) => sum + (repository.last_scan_health ?? 0), 0) / scored.length) : 0
|
||||
const attention = [...portfolio].filter((repository) => repositoryReadiness(repository) !== 'ready').sort((left, right) => triagePriority(right) - triagePriority(left) || (left.last_scan_health ?? -1) - (right.last_scan_health ?? -1) || (Date.parse(right.last_scan_at ?? '') || 0) - (Date.parse(left.last_scan_at ?? '') || 0)).slice(0, 5)
|
||||
const recent = [...portfolio].filter((repository) => repository.last_scan_at).sort((left, right) => (Date.parse(right.last_scan_at ?? '') || 0) - (Date.parse(left.last_scan_at ?? '') || 0)).slice(0, 4)
|
||||
const pinned = pinnedIds.map((id) => portfolio.find((repository) => repository.id === id)).filter((repository): repository is RepositorySummary => Boolean(repository)).slice(0, 3)
|
||||
const stale = portfolio.filter((repository) => repository.stale).length
|
||||
const nextRepository = attention[0] ?? recent[0]
|
||||
const improved = portfolio.filter((repository) => repository.last_scan_resolved_findings > repository.last_scan_added_findings || (repository.last_scan_health_delta ?? 0) > 0).length
|
||||
const regressed = portfolio.filter((repository) => repository.last_scan_added_findings > 0 || (repository.last_scan_health_delta ?? 0) < 0).length
|
||||
|
||||
return (
|
||||
<section className="page-content dashboard-page cockpit-page" aria-labelledby="dashboard-title">
|
||||
<header className="dashboard-command">
|
||||
<div className="command-copy"><span className="eyebrow">Portfolio command deck</span><h1 id="dashboard-title"><span>{averageHealth}</span><small>/100</small> portfolio signal</h1><p>{portfolio.length} retained repository signals · {counts.blocked + counts.warnings} require a deliberate decision · {stale} stale</p><div className="command-trend" aria-label={`${improved} improved repositories and ${regressed} repositories with new signals`}><span><i className="trend-up" />{improved} improving</span><span><i className="trend-watch" />{regressed} new signals</span></div></div>
|
||||
<div className="command-pulse" aria-label={`${averageHealth} average portfolio health`}><svg viewBox="0 0 230 110" aria-hidden="true"><path className="pulse-field" d="M0 81C29 59 43 92 68 65s32-46 57-23 36 14 54-9 31-28 51-10" /><path className="pulse-line" d="M0 79h26l14-22 17 39 21-62 19 42h30l16-24 20 28h37" /><circle cx="203" cy="80" r="5" /></svg><strong>{averageHealth}</strong><span>health signal</span></div>
|
||||
<dl className="command-context"><div><dt>Blockers</dt><dd>{counts.blocked}</dd></div><div><dt>Warnings</dt><dd>{counts.warnings}</dd></div><div><dt>Clear</dt><dd>{counts.ready}</dd></div></dl>
|
||||
</header>
|
||||
|
||||
<nav className="dashboard-readiness-rail" aria-label="Portfolio readiness summary">
|
||||
{(['blocked', 'warnings', 'ready'] as Readiness[]).map((readiness, index) => <button key={readiness} type="button" className={`readiness-rail-segment ${readiness}`} onClick={() => onExploreReadiness(readiness)} aria-label={`Open ${counts[readiness]} ${readinessLabel(readiness).toLowerCase()} repositories in Explorer`}><span>{String(index + 1).padStart(2, '0')} / {readinessLabel(readiness)}</span><strong>{counts[readiness]}</strong><small>{readinessDescription(readiness)} <b>→</b></small></button>)}
|
||||
<button type="button" className="readiness-rail-segment all" onClick={onOpenExplorer}><span>04 / Catalog</span><strong>{portfolio.length}</strong><small>Open repository explorer <b>→</b></small></button>
|
||||
</nav>
|
||||
|
||||
<div className="cockpit-focus-grid">
|
||||
<section className="dashboard-section attention-section" aria-labelledby="attention-title">
|
||||
<div className="section-heading"><div><span className="eyebrow">Triage, in priority order</span><h2 id="attention-title">Why attention is needed</h2></div><button className="text-button" type="button" onClick={() => onExploreReadiness('blocked')}>Open blocked lane</button></div>
|
||||
{attention.length ? <div className="attention-list">{attention.map((repository, index) => <button type="button" className="attention-row" key={repository.id} onClick={() => onOpenRepository(repository)}><span className="attention-rank">{String(index + 1).padStart(2, '0')}</span><RepositoryVisual repository={repository} compact /><span className="attention-copy"><strong>{repository.full_name}</strong><small>{attentionReason(repository)}</small><span className="attention-context"><i>{repository.last_scan_primary_signal?.category ?? readinessLabel(repositoryReadiness(repository))}</i><em>{changeContext(repository)}</em></span></span><span className="attention-score"><b>{repository.last_scan_health ?? '—'}</b><small>{relativeTime(repository.last_scan_at)}</small></span></button>)}</div> : <p className="empty-inline">No repository needs attention right now.</p>}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-section next-move" aria-labelledby="next-move-title">
|
||||
<span className="eyebrow">Recommended next move</span><h2 id="next-move-title">{nextRepository ? nextRepository.full_name : 'Portfolio is clear'}</h2>
|
||||
{nextRepository ? <button type="button" className="next-move-action" onClick={() => onOpenRepository(nextRepository)}><RepositoryVisual repository={nextRepository} /><span><strong>{nextRepository.last_scan_health ?? '—'} health</strong><small>{attentionReason(nextRepository)}</small><em className="next-move-context"><i>{nextRepository.last_scan_primary_signal?.category ?? 'Portfolio signal'}</i>{remediationPath(nextRepository)}</em><b>Open workspace →</b></span></button> : <p className="muted">Keep the next immutable scan as the portfolio baseline.</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<RemediationCenter repositories={portfolio} onOpenRepository={onOpenRepository} />
|
||||
|
||||
<PortfolioAtlas repositories={portfolio} onOpenRepository={onOpenRepository} />
|
||||
|
||||
<div className="dashboard-bottom-grid">
|
||||
<section className="dashboard-section activity-section" aria-labelledby="recent-title"><div className="section-heading"><div><span className="eyebrow">Change feed</span><h2 id="recent-title">Recently scanned</h2></div><button className="text-button" type="button" onClick={onOpenExplorer}>Open explorer</button></div><div className="activity-rail">{recent.map((repository) => <button type="button" key={repository.id} className="activity-rail-item" onClick={() => onOpenRepository(repository)}><span className={`status-orb ${repositoryReadiness(repository)}`} /><span><strong>{repository.full_name}</strong><small>{repository.state === 'scanning' ? 'Analysis in progress' : `Retained scan ${relativeTime(repository.last_scan_at)}`} · {repository.default_branch}</small></span><b>{repository.last_scan_health ?? '—'}</b></button>)}</div></section>
|
||||
<section className="dashboard-section pinned-section" aria-labelledby="pinned-title"><div className="section-heading"><div><span className="eyebrow">Your focus</span><h2 id="pinned-title">Pinned repositories</h2></div></div>{pinned.length ? <div className="pinned-grid">{pinned.map((repository) => <button key={repository.id} type="button" className="pinned-tile" onClick={() => onOpenRepository(repository)}><RepositoryVisual repository={repository} compact /><span><strong>{repository.name}</strong><small>{repository.last_scan_health ?? '—'} health · {readinessLabel(repositoryReadiness(repository))}</small></span><b>→</b></button>)}</div> : <div className="pin-empty"><span>Pin repositories in Explorer to keep active work in this command deck.</span><button className="text-button" type="button" onClick={onOpenExplorer}>Browse repositories</button></div>}</section>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ScanReport } from '../api'
|
||||
|
||||
export function DependenciesView({ report }: { report: ScanReport }) {
|
||||
const dependencySignals = [
|
||||
['Declared dependencies', report.dependencies.declared_dependencies],
|
||||
['Dependency manifests', report.dependencies.manifests.length],
|
||||
['Lockfiles', report.dependencies.lockfiles.length],
|
||||
['Unbounded versions', report.dependencies.unbounded_dependencies],
|
||||
['Unpinned versions', report.dependencies.unpinned_dependencies],
|
||||
]
|
||||
return <div className="view-stack"><section className="section-card dependency-hero"><span className="eyebrow">Dependency intelligence</span><h2>{report.dependencies.declared_dependencies} declared dependencies</h2><p className="muted">Manifest and lockfile signals from the selected immutable repository revision.</p><div className="dependency-metrics">{dependencySignals.map(([label, value]) => <div key={label as string}><strong>{value}</strong><span>{label}</span></div>)}</div></section><section className="section-card"><div className="section-heading"><div><span className="eyebrow">Inventory</span><h3>Manifest coverage</h3></div></div>{report.dependencies.manifests.length ? <div className="manifest-list">{report.dependencies.manifests.map((manifest) => <code key={manifest}>{manifest}</code>)}</div> : <div className="empty-inline">No supported dependency manifest was detected in this snapshot.</div>}<div className="section-heading dependency-lock-heading"><div><span className="eyebrow">Reproducibility</span><h3>Lockfiles</h3></div></div>{report.dependencies.lockfiles.length ? <div className="manifest-list">{report.dependencies.lockfiles.map((lockfile) => <code key={lockfile}>{lockfile}</code>)}</div> : <div className="empty-inline">No lockfile was detected for this snapshot.</div>}</section></div>
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { Finding, ScanReport, Severity } from '../api'
|
||||
|
||||
function findingLocation(finding: Finding) {
|
||||
if (!finding.path) return 'Repository-level'
|
||||
return `${finding.path}${finding.line ? `:${finding.line}` : ''}`
|
||||
}
|
||||
|
||||
export function FindingsView({ report }: { report: ScanReport }) {
|
||||
const [severity, setSeverity] = useState<'all' | Severity>('all')
|
||||
const [category, setCategory] = useState('all')
|
||||
const [query, setQuery] = useState('')
|
||||
const categories = useMemo(() => [...new Set(report.findings.map((item) => item.category))].sort(), [report.findings])
|
||||
const suppressedCount = report.findings.filter((item) => item.suppressed).length
|
||||
|
||||
useEffect(() => {
|
||||
if (category !== 'all' && !categories.includes(category)) setCategory('all')
|
||||
}, [category, categories])
|
||||
|
||||
const findings = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
return report.findings.filter((item) => {
|
||||
if (severity !== 'all' && item.severity !== severity) return false
|
||||
if (category !== 'all' && item.category !== category) return false
|
||||
if (!needle) return true
|
||||
return `${item.message} ${item.path ?? ''} ${item.code}`.toLowerCase().includes(needle)
|
||||
})
|
||||
}, [report.findings, severity, category, query])
|
||||
|
||||
return (
|
||||
<section className="section-card">
|
||||
<div className="section-heading findings-heading">
|
||||
<div><span className="eyebrow">Findings</span><h3>Explain every deduction</h3></div>
|
||||
<span className="section-note">Showing {findings.length} of {report.findings.length}. {suppressedCount ? `${suppressedCount} accepted-risk finding(s) suppressed. ` : ''}Potential secret values are intentionally redacted.</span>
|
||||
</div>
|
||||
<div className="finding-filters">
|
||||
<input aria-label="Filter findings" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter message, code or path" />
|
||||
<select aria-label="Filter findings by severity" value={severity} onChange={(event) => setSeverity(event.target.value as 'all' | Severity)}>
|
||||
<option value="all">All severities</option>
|
||||
<option value="blocker">Blockers</option>
|
||||
<option value="warning">Warnings</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<select aria-label="Filter findings by category" value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||
<option value="all">All categories</option>
|
||||
{categories.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="findings-list">
|
||||
{findings.map((finding, index) => (
|
||||
<details className={`finding ${finding.suppressed ? 'suppressed' : ''}`} key={`${finding.code}-${finding.path}-${finding.line}-${index}`}>
|
||||
<summary className="finding-summary">
|
||||
<span className={`severity severity-${finding.severity}`}>{finding.suppressed ? 'accepted' : finding.severity}</span>
|
||||
<span className="finding-copy">
|
||||
<strong>{finding.message}</strong>
|
||||
<span>{finding.category} · {finding.code}{finding.path ? ` · ${findingLocation(finding)}` : ''}</span>
|
||||
</span>
|
||||
<span className="deduction">{finding.deduction ? `−${finding.deduction}` : 'info'}</span>
|
||||
</summary>
|
||||
<div className="finding-details">
|
||||
<dl className="finding-detail-grid">
|
||||
<div><dt>Severity</dt><dd>{finding.suppressed ? 'Accepted risk' : finding.severity}</dd></div>
|
||||
<div><dt>Rule</dt><dd><code>{finding.code}</code></dd></div>
|
||||
<div><dt>Category</dt><dd>{finding.category}</dd></div>
|
||||
<div><dt>Location</dt><dd><code>{findingLocation(finding)}</code></dd></div>
|
||||
<div><dt>Deduction</dt><dd>{finding.deduction ? `−${finding.deduction} points` : 'Informational'}</dd></div>
|
||||
<div><dt>Status</dt><dd>{finding.suppressed ? 'Suppressed' : 'Active'}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
{!findings.length && <div className="empty-inline">No findings match the current filters.</div>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ScanComparison, ScanSummary, TrendPoint } from '../api'
|
||||
|
||||
export function HistoryView({ trend, comparison, scanSummaries = [], currentScanId, onSelectScan }: { trend: TrendPoint[]; comparison: ScanComparison | null; scanSummaries?: ScanSummary[]; currentScanId?: string; onSelectScan?: (summary: ScanSummary) => void }) {
|
||||
return (
|
||||
<div className="view-stack">
|
||||
<section className="section-card">
|
||||
<div className="section-heading">
|
||||
<div><span className="eyebrow">History</span><h3>Repository health over time</h3></div>
|
||||
<span className="section-note">Each bar is one persisted scan snapshot.</span>
|
||||
</div>
|
||||
{trend.length ? (
|
||||
<div className="trend-chart" aria-label="Repository health trend">
|
||||
{trend.map((point) => (
|
||||
<div className="trend-column" key={point.scan_id} title={`${new Date(point.scanned_at).toLocaleString()} · ${point.health_score}/100`}>
|
||||
<span>{point.health_score}</span>
|
||||
<div className="trend-track"><i style={{ height: `${Math.max(4, point.health_score)}%` }} /></div>
|
||||
<small>{new Date(point.scanned_at).toLocaleDateString()}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="empty-inline">Run multiple scans of the same repository to build a trend.</div>}
|
||||
</section>
|
||||
|
||||
<section className="comparison-grid">
|
||||
<article className="section-card">
|
||||
<span className="eyebrow">Since previous scan</span>
|
||||
<h3>{comparison?.health_score_delta == null ? 'No baseline yet' : `${comparison.health_score_delta >= 0 ? '+' : ''}${comparison.health_score_delta} health points`}</h3>
|
||||
{comparison?.previous_scan_id ? (
|
||||
<div className="delta-grid">
|
||||
{Object.entries(comparison.category_score_deltas).map(([name, value]) => (
|
||||
<div key={name}><span>{name}</span><strong className={value > 0 ? 'positive' : value < 0 ? 'negative' : ''}>{value > 0 ? '+' : ''}{value}</strong></div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="muted">The first scan establishes the baseline for comparisons.</p>}
|
||||
</article>
|
||||
|
||||
<article className="section-card">
|
||||
<span className="eyebrow">Finding delta</span>
|
||||
<h3>{comparison?.added_findings.length ?? 0} added · {comparison?.resolved_findings.length ?? 0} resolved</h3>
|
||||
<div className="delta-findings">
|
||||
{comparison?.resolved_findings.slice(0, 6).map((item, index) => (
|
||||
<div className="resolved" key={`resolved-${item.code}-${index}`}>Resolved · {item.message}</div>
|
||||
))}
|
||||
{comparison?.added_findings.slice(0, 6).map((item, index) => (
|
||||
<div className="added" key={`added-${item.code}-${index}`}>Added · {item.message}</div>
|
||||
))}
|
||||
{comparison?.previous_scan_id && !comparison.added_findings.length && !comparison.resolved_findings.length && (
|
||||
<div className="empty-inline">No finding-level changes since the previous scan.</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="section-card scan-run-card">
|
||||
<div className="section-heading">
|
||||
<div><span className="eyebrow">Persisted snapshots</span><h3>Scan runs</h3></div>
|
||||
<span className="section-note">Select a revision to inspect its report.</span>
|
||||
</div>
|
||||
{scanSummaries.length ? (
|
||||
<div className="scan-run-list">
|
||||
{scanSummaries.map((item) => (
|
||||
<button type="button" className={`scan-run-row ${item.id === currentScanId ? 'selected' : ''}`} key={item.id} onClick={() => onSelectScan?.(item)} disabled={!onSelectScan}>
|
||||
<span><strong>{item.actual_revision ? item.actual_revision.slice(0, 10) : 'not acquired'}</strong><small>{new Date(item.scanned_at).toLocaleString()} · {item.trigger} · {item.findings} finding(s)</small></span>
|
||||
<strong>{item.health_score}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="empty-inline">Analyze this repository to start its history.</div>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ScanReport } from '../api'
|
||||
|
||||
export function HotspotsView({ report }: { report: ScanReport }) {
|
||||
return (
|
||||
<section className="section-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Engineering hotspots</span>
|
||||
<h3>Where maintenance risk concentrates</h3>
|
||||
</div>
|
||||
<span className="section-note">Score combines size, debt markers, recent Git churn and local coupling.</span>
|
||||
</div>
|
||||
|
||||
<div className="hotspot-list">
|
||||
{report.hotspots.map((hotspot, index) => (
|
||||
<article className="hotspot-row" key={hotspot.path}>
|
||||
<div className="hotspot-rank">{index + 1}</div>
|
||||
<div className="hotspot-main">
|
||||
<div className="hotspot-title"><strong>{hotspot.path}</strong><span>{hotspot.language}</span></div>
|
||||
<div className="hotspot-reasons">
|
||||
{hotspot.reasons.length ? hotspot.reasons.map((reason) => <span key={reason}>{reason}</span>) : <span>No dominant risk signal</span>}
|
||||
</div>
|
||||
<div className="bar hotspot-bar"><i style={{ width: `${hotspot.score}%` }} /></div>
|
||||
</div>
|
||||
<div className="hotspot-metrics">
|
||||
<strong>{hotspot.score}</strong>
|
||||
<small>{hotspot.lines_of_code} LOC</small>
|
||||
<small>{hotspot.churn} churn</small>
|
||||
<small>{hotspot.inbound + hotspot.outbound} edges</small>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!report.hotspots.length && <div className="empty-inline">No supported source files were available for hotspot scoring.</div>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ScanComparison, ScanReport } from '../api'
|
||||
|
||||
export function OverviewView({ report, comparison, onShowFindings }: { report: ScanReport; comparison: ScanComparison | null; onShowFindings?: () => void }) {
|
||||
const debt = report.metrics.todo + report.metrics.fixme + report.metrics.hack + report.metrics.xxx
|
||||
const activeFindings = report.findings.filter((item) => !item.suppressed)
|
||||
const blockers = activeFindings.filter((item) => item.severity === 'blocker').length
|
||||
const warnings = activeFindings.filter((item) => item.severity === 'warning').length
|
||||
const languageTotal = Object.values(report.languages).reduce((sum, value) => sum + value, 0)
|
||||
const primaryFinding = activeFindings.find((item) => item.severity === 'blocker') ?? activeFindings.find((item) => item.severity === 'warning')
|
||||
const healthDelta = comparison?.health_score_delta
|
||||
const readinessState = blockers ? 'blocked' : warnings ? 'warnings' : 'ready'
|
||||
const readinessCopy = blockers ? `${blockers} blocker(s) need attention before release.` : warnings ? `${warnings} warning(s) are worth reviewing before release.` : 'No active blockers or warnings in this snapshot.'
|
||||
const lowSignal = report.metrics.source_files === 0 && report.metrics.lines_of_code === 0 && report.metrics.test_files === 0 && report.dependencies.declared_dependencies === 0
|
||||
const topFindings = activeFindings.slice().sort((left, right) => (left.severity === 'blocker' ? -1 : 0) - (right.severity === 'blocker' ? -1 : 0) || right.deduction - left.deduction).slice(0, 4)
|
||||
|
||||
return (
|
||||
<div className="view-stack">
|
||||
{lowSignal && <div className="notice-banner low-signal-notice" role="status">This repository contains little or no analyzable source code. Zero-value metrics are kept as context while readiness and the next scan remain the useful signals.</div>}
|
||||
<section className={`metric-grid ${lowSignal ? 'low-signal' : ''}`} aria-label="Repository metrics">
|
||||
<article><span>Source files</span><strong>{report.metrics.source_files.toLocaleString()}</strong><small>Analyzed source footprint</small></article>
|
||||
<article><span>Lines of code</span><strong>{report.metrics.lines_of_code.toLocaleString()}</strong><small>Across supported files</small></article>
|
||||
<article><span>Tests</span><strong>{report.metrics.test_files.toLocaleString()}</strong><small>{report.tests.frameworks.length ? report.tests.frameworks.join(', ') : 'No test framework detected'}</small></article>
|
||||
<article><span>Dependencies</span><strong>{report.dependencies.declared_dependencies.toLocaleString()}</strong><small>{report.dependencies.manifests.length ? `${report.dependencies.manifests.length} manifest(s)` : 'No manifests detected'}</small></article>
|
||||
<article><span>Debt markers</span><strong>{debt.toLocaleString()}</strong><small>{debt ? 'TODO, FIXME and related markers' : 'No markers detected'}</small></article>
|
||||
<article><span>Findings</span><strong>{activeFindings.length.toLocaleString()}</strong><small>{report.findings.length - activeFindings.length} suppressed</small></article>
|
||||
</section>
|
||||
|
||||
<section className="insight-grid" aria-label="Repository insights">
|
||||
<article className={`insight-card insight-${readinessState}`}><span className="eyebrow">Readiness signal</span><strong>{report.release_readiness}</strong><p>{readinessCopy}</p></article>
|
||||
<article className="insight-card"><span className="eyebrow">What to fix first</span><strong>{primaryFinding ? primaryFinding.message : 'No active finding requires attention'}</strong><p>{primaryFinding ? `${primaryFinding.category} · ${primaryFinding.code}${primaryFinding.path ? ` · ${primaryFinding.path}` : ''}` : 'Keep the next scan as your baseline.'}</p></article>
|
||||
<article className="insight-card"><span className="eyebrow">Changed since previous</span><strong>{healthDelta == null ? 'Baseline snapshot' : `${healthDelta >= 0 ? '+' : ''}${healthDelta} health points`}</strong><p>{healthDelta == null ? 'A later scan will unlock comparison context.' : `${comparison?.added_findings.length ?? 0} added · ${comparison?.resolved_findings.length ?? 0} resolved finding(s).`}</p></article>
|
||||
</section>
|
||||
|
||||
<section className="overview-intelligence">
|
||||
<article className="section-card change-brief">
|
||||
<div className="section-heading"><div><span className="eyebrow">Since previous revision</span><h3>What changed?</h3></div>{healthDelta != null && <strong className={healthDelta >= 0 ? 'positive' : 'negative'}>{healthDelta >= 0 ? '+' : ''}{healthDelta} health</strong>}</div>
|
||||
{comparison?.previous_scan_id ? <div className="change-grid"><span><b>{comparison.health_score_delta == null ? '—' : `${comparison.health_score_delta >= 0 ? '+' : ''}${comparison.health_score_delta}`}</b>health</span><span><b>{comparison.added_findings.length ? `+${comparison.added_findings.length}` : '0'}</b>findings added</span><span><b>{comparison.resolved_findings.length ? `−${comparison.resolved_findings.length}` : '0'}</b>findings resolved</span><span><b>{comparison.metric_deltas.lines_of_code == null ? '—' : `${comparison.metric_deltas.lines_of_code >= 0 ? '+' : ''}${comparison.metric_deltas.lines_of_code}`}</b>lines of code</span></div> : <p className="muted">This retained snapshot establishes the comparison baseline for the next immutable revision.</p>}
|
||||
</article>
|
||||
<article className="section-card top-findings-card">
|
||||
<div className="section-heading"><div><span className="eyebrow">Priority review</span><h3>Top findings</h3></div>{onShowFindings ? <button className="text-button" type="button" onClick={onShowFindings}>View all {activeFindings.length} findings</button> : <span className="section-note">All {activeFindings.length} findings are available in Findings.</span>}</div>
|
||||
{topFindings.length ? <div className="top-findings-list">{topFindings.map((finding) => <div key={`${finding.code}-${finding.fingerprint ?? finding.path}`}><span className={`severity severity-${finding.severity}`}>{finding.severity}</span><span><strong>{finding.message}</strong><small>{finding.category}{finding.path ? ` · ${finding.path}` : ''}</small></span><b>−{finding.deduction}</b></div>)}</div> : <div className="empty-inline">No active findings need attention in this snapshot.</div>}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="overview-columns">
|
||||
<article className="section-card">
|
||||
<div className="section-heading"><div><span className="eyebrow">Health model</span><h3>Category score rows</h3></div>{healthDelta != null && <span className={`delta ${healthDelta >= 0 ? 'positive' : 'negative'}`}>{healthDelta >= 0 ? '+' : ''}{healthDelta} since previous</span>}</div>
|
||||
<div className="score-grid">
|
||||
{Object.entries(report.category_scores).map(([name, value]) => (
|
||||
<article className="score-card" key={name}>
|
||||
<div><span>{name}</span><strong>{value}</strong></div>
|
||||
<div className="bar"><i style={{ width: `${value}%` }} /></div>
|
||||
{comparison?.category_score_deltas[name] != null && comparison.category_score_deltas[name] !== 0 && <small className={comparison.category_score_deltas[name] > 0 ? 'positive' : 'negative'}>{comparison.category_score_deltas[name] > 0 ? '+' : ''}{comparison.category_score_deltas[name]} since previous</small>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="section-card release-gate-card"><span className="eyebrow">Release gate</span><h3>{report.release_gate?.status ?? report.release_readiness}</h3><div className="gate-list">{report.release_gate?.checks.length ? report.release_gate.checks.map((check) => <div className="gate-check" key={check.key}><span className={`gate-state gate-${check.state}`}>{check.state}</span><div><strong>{check.label}</strong><small>{check.detail}</small></div></div>) : <div className="empty-inline">No release-gate checks were returned for this snapshot.</div>}</div></article>
|
||||
</section>
|
||||
|
||||
<section className="overview-columns signals-columns">
|
||||
<article className="section-card"><span className="eyebrow">Languages</span><h3>Code footprint</h3><div className="language-list">{Object.entries(report.languages).slice(0, 12).map(([language, lines]) => <div className="language" key={language}><div><span>{language}</span><strong>{lines.toLocaleString()} LOC</strong></div><div className="bar"><i style={{ width: `${languageTotal ? (lines / languageTotal) * 100 : 0}%` }} /></div></div>)}{!Object.keys(report.languages).length && <div className="empty-inline">No language signals were detected.</div>}</div></article>
|
||||
<div className="signal-stack">
|
||||
<article className="section-card signal-card"><span className="eyebrow">Testing & CI</span><h3>{report.tests.frameworks.length ? report.tests.frameworks.join(', ') : 'Testing signals unavailable'}</h3><dl className="compact-dl"><div><dt>Test/source ratio</dt><dd>{report.tests.test_source_ratio != null ? `${report.tests.test_source_ratio}%` : 'Not measured'}</dd></div><div><dt>CI workflows</dt><dd>{report.tests.ci_workflows.length || 'None detected'}</dd></div><div><dt>Coverage artifacts</dt><dd>{report.tests.coverage_artifacts.length || 'None detected'}</dd></div></dl></article>
|
||||
<article className="section-card signal-card"><span className="eyebrow">Quick signals</span><h3>Repository context</h3><dl className="compact-dl"><div><dt>Git commits</dt><dd>{report.git.commit_count ?? 'Unavailable'}</dd></div><div><dt>Contributors</dt><dd>{report.git.contributor_count ?? 'Unavailable'}</dd></div><div><dt>Manifests</dt><dd>{report.dependencies.manifests.length || 'None detected'}</dd></div><div><dt>Lockfiles</dt><dd>{report.dependencies.lockfiles.length || 'None detected'}</dd></div><div><dt>Graph edges</dt><dd>{report.architecture.total_edges || report.architecture.edges.length || 'None detected'}</dd></div></dl></article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PortfolioAtlas } from './PortfolioAtlas'
|
||||
import { testRepository } from '../test-fixtures'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('PortfolioAtlas', () => {
|
||||
it('keeps every repository operable while using the bounded compact detail mode at portfolio scale', () => {
|
||||
const repositories = Array.from({ length: 500 }, (_, index) => testRepository(`gitea:${index}`, `Jens/Repo-${index}`, {
|
||||
last_scan_health: 40 + (index % 60),
|
||||
last_scan_readiness: index % 3 === 0 ? 'BLOCKED' : index % 3 === 1 ? 'READY WITH WARNINGS' : 'READY',
|
||||
}))
|
||||
const onOpenRepository = vi.fn()
|
||||
const { container } = render(<PortfolioAtlas repositories={repositories} onOpenRepository={onOpenRepository} />)
|
||||
|
||||
expect(container.querySelector('.portfolio-atlas')?.getAttribute('data-density')).toBe('compact')
|
||||
expect(screen.getAllByRole('button', { name: /Open Jens\/Repo-/ })).toHaveLength(500)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open Jens\/Repo-0/ }))
|
||||
expect(onOpenRepository).toHaveBeenCalledWith(repositories[0])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { RepositorySummary } from '../api'
|
||||
import { readinessLabel, repositoryReadiness, type Readiness } from '../catalog'
|
||||
|
||||
type PortfolioAtlasProps = {
|
||||
repositories: RepositorySummary[]
|
||||
onOpenRepository: (repository: RepositorySummary) => void
|
||||
}
|
||||
|
||||
const laneOrder: Readiness[] = ['blocked', 'warnings', 'ready']
|
||||
|
||||
const laneCopy: Record<Readiness, { title: string; description: string }> = {
|
||||
blocked: { title: 'Intervention lane', description: 'Release-blocking repository signals' },
|
||||
warnings: { title: 'Review lane', description: 'Warnings worth resolving before the next release' },
|
||||
ready: { title: 'Clear lane', description: 'Repositories with a clear release signal' },
|
||||
}
|
||||
|
||||
function scanRhythm(repository: RepositorySummary) {
|
||||
if (repository.state === 'scanning') return 'scan in progress'
|
||||
if (repository.stale) return 'stale scan'
|
||||
if (!repository.last_scan_at) return 'not scanned'
|
||||
const difference = Date.now() - Date.parse(repository.last_scan_at)
|
||||
if (Number.isNaN(difference)) return 'scan time unknown'
|
||||
if (difference < 3_600_000) return 'scanned this hour'
|
||||
if (difference < 86_400_000) return `scanned ${Math.floor(difference / 3_600_000)}h ago`
|
||||
return `scanned ${Math.floor(difference / 86_400_000)}d ago`
|
||||
}
|
||||
|
||||
function signalStrength(repository: RepositorySummary) {
|
||||
return Math.max(16, Math.min(100, repository.last_scan_health ?? 36))
|
||||
}
|
||||
|
||||
export function PortfolioAtlas({ repositories, onOpenRepository }: PortfolioAtlasProps) {
|
||||
const ordered = [...repositories].sort((left, right) => {
|
||||
const lane = laneOrder.indexOf(repositoryReadiness(left)) - laneOrder.indexOf(repositoryReadiness(right))
|
||||
return lane || (left.last_scan_health ?? -1) - (right.last_scan_health ?? -1) || left.full_name.localeCompare(right.full_name)
|
||||
})
|
||||
const density = ordered.length > 160 ? 'compact' : ordered.length > 96 ? 'dense' : 'comfortable'
|
||||
|
||||
return (
|
||||
<section className="portfolio-atlas dashboard-section" aria-labelledby="atlas-title" data-density={density}>
|
||||
<div className="section-heading atlas-heading">
|
||||
<div><span className="eyebrow">Portfolio topology</span><h2 id="atlas-title">Repository signal landscape</h2></div>
|
||||
<span className="section-note">{ordered.length} repositories · every signal opens its workspace</span>
|
||||
</div>
|
||||
<div className="atlas-field">
|
||||
<svg className="atlas-contours" viewBox="0 0 1200 330" aria-hidden="true" preserveAspectRatio="none"><path d="M-20 270C118 216 190 318 335 246S525 172 656 230s250 38 390-55 154 3 210-38" /><path d="M-30 178c127-71 214 32 350-44s283 9 425-53 248 24 478-72" /><path d="M-10 90c156 27 230-67 390-8s209-18 362 24 252-56 475-13" /></svg>
|
||||
<div className="atlas-bands">
|
||||
{laneOrder.map((lane, laneIndex) => {
|
||||
const laneRepositories = ordered.filter((repository) => repositoryReadiness(repository) === lane)
|
||||
const copy = laneCopy[lane]
|
||||
return <section className={`atlas-band ${lane}`} key={lane} aria-labelledby={`atlas-${lane}-title`} data-lane={lane}>
|
||||
<header><i className="atlas-lane-beacon" aria-hidden="true" /><span>{String(laneIndex + 1).padStart(2, '0')} / {copy.title}</span><strong id={`atlas-${lane}-title`}>{laneRepositories.length}</strong><small>{copy.description}</small></header>
|
||||
<div className="atlas-river"><ol className="atlas-tiles" aria-label={`${readinessLabel(lane)} repositories`}>
|
||||
{laneRepositories.map((repository, index) => {
|
||||
const health = repository.last_scan_health ?? 0
|
||||
const style = { '--signal-strength': `${signalStrength(repository)}%`, '--signal-index': index, '--signal-phase': index % 7 } as CSSProperties
|
||||
const primary = repository.last_scan_primary_signal
|
||||
const signalDetail = primary ? `${primary.count} ${primary.category.toLowerCase()} signal${primary.count === 1 ? '' : 's'}` : 'no primary finding retained'
|
||||
return <li key={repository.id}><button type="button" className={`atlas-repository ${repository.stale ? 'stale' : ''} ${repository.state === 'scanning' ? 'scanning' : ''}`} onClick={() => onOpenRepository(repository)} style={style} aria-label={`Open ${repository.full_name}: ${health || 'not scanned'} health, ${readinessLabel(lane)}, ${signalDetail}, ${scanRhythm(repository)}`}>
|
||||
<span className="atlas-repository-orbit" aria-hidden="true" />
|
||||
<span className="atlas-repository-name">{repository.name.slice(0, 3).toUpperCase()}</span>
|
||||
<span className="atlas-repository-health">{health || '—'}</span>
|
||||
<i aria-hidden="true" />
|
||||
</button></li>
|
||||
})}
|
||||
</ol></div>
|
||||
</section>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="atlas-legend"><span><i className="blocked" />Health bar height follows the retained score.</span><span><i className="stale" />A dashed frame means the scan needs refreshing.</span><span><i className="scanning" />A live ring means analysis is in progress.</span></p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { RepositorySummary } from '../api'
|
||||
import { repositoryReadiness } from '../catalog'
|
||||
|
||||
type RemediationCenterProps = {
|
||||
repositories: RepositorySummary[]
|
||||
onOpenRepository: (repository: RepositorySummary) => void
|
||||
}
|
||||
|
||||
export function RemediationCenter({ repositories, onOpenRepository }: RemediationCenterProps) {
|
||||
const candidates = [...repositories]
|
||||
.filter((repository) => !repository.archived && repository.last_scan_primary_signal && repositoryReadiness(repository) !== 'ready')
|
||||
.sort((left, right) => (right.last_scan_priority ?? 0) - (left.last_scan_priority ?? 0) || left.full_name.localeCompare(right.full_name))
|
||||
.slice(0, 5)
|
||||
|
||||
if (!candidates.length) return null
|
||||
|
||||
return (
|
||||
<section className="dashboard-section remediation-center" aria-labelledby="remediation-center-title">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Backend-ranked candidates</span>
|
||||
<h2 id="remediation-center-title">Remediation Center</h2>
|
||||
</div>
|
||||
<span className="remediation-live">retained evidence</span>
|
||||
</div>
|
||||
<ol className="remediation-list">
|
||||
{candidates.map((repository) => {
|
||||
const signal = repository.last_scan_primary_signal
|
||||
const guidance = signal?.guidance
|
||||
if (!signal) return null
|
||||
return (
|
||||
<li key={repository.id}>
|
||||
<button type="button" className="remediation-row" onClick={() => onOpenRepository(repository)}>
|
||||
<span className="remediation-priority" aria-label={`Priority ${repository.last_scan_priority ?? 0}`}>{repository.last_scan_priority ?? 0}</span>
|
||||
<span className="remediation-copy">
|
||||
<strong>{repository.full_name}</strong>
|
||||
<small>{guidance?.remediation_class ?? signal.category} · {guidance?.confidence ?? 'review'} confidence</small>
|
||||
<em>{guidance?.why ?? signal.message}</em>
|
||||
<b>{guidance?.recommended_action ?? 'Open the retained evidence and select the smallest safe next action.'}</b>
|
||||
</span>
|
||||
<span className="remediation-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { RepositoryVisual } from './RepositoryVisual'
|
||||
import { testRepository } from '../test-fixtures'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('RepositoryVisual', () => {
|
||||
it('renders a stable, labelled fallback fingerprint from repository data', () => {
|
||||
const repository = testRepository('gitea:23', 'Jens/SignalMap', { last_scan_revision: 'a-repeatable-revision' })
|
||||
const { container } = render(<><RepositoryVisual repository={repository} /><RepositoryVisual repository={repository} /></>)
|
||||
const visuals = screen.getAllByRole('img', { name: /SignalMap repository fingerprint/ })
|
||||
|
||||
expect(visuals).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.repository-visual svg')[0].innerHTML).toBe(container.querySelectorAll('.repository-visual svg')[1].innerHTML)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { RepositorySummary, ScanReport } from '../api'
|
||||
import { readinessLabel, repositoryReadiness } from '../catalog'
|
||||
|
||||
type RepositoryVisualProps = {
|
||||
repository: RepositorySummary
|
||||
report?: ScanReport | null
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
type VisualNode = { x: number; y: number; radius: number; hot: boolean; weight: number }
|
||||
|
||||
function hash(seed: string) {
|
||||
let result = 2166136261
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
result ^= seed.charCodeAt(index)
|
||||
result = Math.imul(result, 16777619)
|
||||
}
|
||||
return result >>> 0
|
||||
}
|
||||
|
||||
function random(seed: number) {
|
||||
let value = seed >>> 0
|
||||
return () => {
|
||||
value += 0x6d2b79f5
|
||||
let output = value
|
||||
output = Math.imul(output ^ (output >>> 15), output | 1)
|
||||
output ^= output + Math.imul(output ^ (output >>> 7), output | 61)
|
||||
return ((output ^ (output >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function nodesFor(repository: RepositorySummary, report?: ScanReport | null): VisualNode[] {
|
||||
const source = report?.architecture.nodes
|
||||
if (source?.length) {
|
||||
return [...source]
|
||||
.sort((left, right) => right.hotspot_score - left.hotspot_score || (right.inbound + right.outbound) - (left.inbound + left.outbound))
|
||||
.slice(0, 14)
|
||||
.map((node, index, nodes) => {
|
||||
const weight = node.inbound + node.outbound + 1
|
||||
const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2
|
||||
const ring = 24 + Math.min(17, weight * 1.7)
|
||||
return { x: 80 + Math.cos(angle) * ring, y: 48 + Math.sin(angle) * ring * .74, radius: 2.5 + Math.min(5.4, Math.sqrt(weight) * .95), hot: node.hotspot_score >= 55, weight }
|
||||
})
|
||||
}
|
||||
|
||||
const take = random(hash(`${repository.id}:${repository.last_scan_revision ?? repository.latest_revision ?? ''}`))
|
||||
const health = repository.last_scan_health ?? 54
|
||||
const count = 7 + Math.floor(take() * 6)
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const angle = (index / count) * Math.PI * 2 + take() * .44
|
||||
const ring = 20 + take() * 28
|
||||
return { x: 80 + Math.cos(angle) * ring, y: 48 + Math.sin(angle) * ring * .72, radius: 2.4 + take() * 3.8, hot: index === 0 || (health < 70 && index % 3 === 0), weight: 1 + Math.round(take() * 6) }
|
||||
})
|
||||
}
|
||||
|
||||
function primaryLanguage(report?: ScanReport | null) {
|
||||
return Object.entries(report?.languages ?? {}).sort((left, right) => right[1] - left[1])[0]?.[0]
|
||||
}
|
||||
|
||||
/** A deterministic project illustration driven by real architecture when available. */
|
||||
export function RepositoryVisual({ repository, report, compact = false, className = '' }: RepositoryVisualProps) {
|
||||
const nodes = nodesFor(repository, report)
|
||||
const readiness = repositoryReadiness(repository)
|
||||
const architectureBacked = Boolean(report?.architecture.nodes.length)
|
||||
const seed = hash(repository.id).toString(36)
|
||||
const health = repository.last_scan_health ?? 0
|
||||
const language = primaryLanguage(report)
|
||||
const healthLine = 65 - Math.max(0, Math.min(100, health)) * .3
|
||||
const label = `${repository.full_name} ${architectureBacked ? 'architecture-backed' : 'repository'} fingerprint, ${readinessLabel(readiness)} readiness${language ? `, ${language} primary language` : ''}`
|
||||
const core = nodes[0] ?? { x: 80, y: 48, radius: 4, hot: false, weight: 1 }
|
||||
const nodeLinks = nodes.slice(1).map((node, index) => ({ from: nodes[index % Math.max(1, nodes.length - 1)] ?? core, to: node }))
|
||||
|
||||
return (
|
||||
<div className={`repository-visual ${compact ? 'compact' : ''} state-${readiness} ${architectureBacked ? 'architecture-backed' : 'catalog-backed'} ${className}`} aria-label={label} role="img">
|
||||
<svg viewBox="0 0 160 96" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id={`visual-wash-${seed}`} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="currentColor" stopOpacity=".3" /><stop offset="1" stopColor="currentColor" stopOpacity="0" /></linearGradient>
|
||||
<linearGradient id={`visual-flow-${seed}`} x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="currentColor" stopOpacity="0" /><stop offset=".5" stopColor="currentColor" stopOpacity=".78" /><stop offset="1" stopColor="currentColor" stopOpacity=".08" /></linearGradient>
|
||||
</defs>
|
||||
<rect className="visual-wash" x="1" y="1" width="158" height="94" rx="13" fill={`url(#visual-wash-${seed})`} />
|
||||
<path className="visual-gridline visual-gridline-one" d="M10 19H150M10 48H150M10 77H150" fill="none" stroke="currentColor" strokeOpacity=".07" strokeWidth=".7" />
|
||||
<ellipse className="visual-orbit orbit-one" cx={core.x} cy={core.y} rx="26" ry="14" fill="none" stroke="currentColor" strokeOpacity=".12" strokeWidth=".9" strokeDasharray="2 5" />
|
||||
<ellipse className="visual-orbit orbit-two" cx={core.x} cy={core.y} rx="39" ry="24" fill="none" stroke="currentColor" strokeOpacity=".08" strokeWidth=".8" />
|
||||
<path className="visual-contour contour-one" d="M6 71C34 46 42 83 73 60S119 31 154 46" fill="none" stroke="currentColor" strokeOpacity=".17" strokeWidth="1" />
|
||||
<path className="visual-contour contour-two" d="M-3 30C34 12 52 42 86 24S132 18 164 34" fill="none" stroke="currentColor" strokeOpacity=".1" strokeWidth="1" strokeDasharray="3 5" />
|
||||
{nodeLinks.map(({ from, to }, index) => { const bend = index % 2 ? -7 : 7; return <path className="visual-link" key={`${from.x}-${to.x}-${index}`} d={`M${from.x} ${from.y} Q${(from.x + to.x) / 2 + bend} ${(from.y + to.y) / 2 - bend} ${to.x} ${to.y}`} fill="none" stroke={`url(#visual-flow-${seed})`} strokeWidth={Math.min(1.8, .65 + to.weight * .1)} /> })}
|
||||
<path className="visual-health-line" d={`M7 ${healthLine + 12} C34 ${healthLine - 8}, 50 ${healthLine + 8}, 78 ${healthLine} S124 ${healthLine - 12}, 153 ${healthLine - 4}`} fill="none" stroke="currentColor" strokeOpacity=".82" strokeWidth="1.35" />
|
||||
{nodes.map((node, index) => <g className={`visual-node ${node.hot ? 'hot' : ''}`} key={`${node.x}-${node.y}-${index}`}><circle cx={node.x} cy={node.y} r={node.radius + (node.hot ? 2.3 : 1.2)} fill="currentColor" fillOpacity={node.hot ? '.12' : '.07'} /><circle cx={node.x} cy={node.y} r={node.radius} fill="currentColor" fillOpacity={node.hot ? '.94' : '.63'} /></g>)}
|
||||
<circle className="visual-core-halo" cx={core.x} cy={core.y} r="10.5" fill="none" stroke="currentColor" strokeOpacity=".25" strokeWidth="1" />
|
||||
<circle className="visual-core" cx={core.x} cy={core.y} r="5.2" fill="var(--surface-strong)" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle className="visual-core-beacon" cx={core.x} cy={core.y} r="2" fill="currentColor" />
|
||||
<path d={`M${core.x - 3.4} ${core.y}h1.8l1.45-3.1 2.05 6.2 1.5-3.1h3.4`} fill="none" stroke="currentColor" strokeWidth="1.45" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<rect className="visual-state-bar" x="8" y="84" width={`${Math.max(12, health * 1.42)}`} height="2" rx="1" fill="currentColor" fillOpacity=".85" />
|
||||
</svg>
|
||||
{!compact && <span className="visual-caption" aria-hidden="true"><i>{architectureBacked ? 'ARCH' : 'SIGNAL'}</i><b>{language ?? `${health || '—'} health`}</b></span>}
|
||||
{repository.state === 'scanning' && <span className="visual-scan-pulse" aria-hidden="true" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ScanSummary } from '../api'
|
||||
|
||||
export function ScanRunsView({ scanSummaries, currentScanId, onSelectScan }: { scanSummaries: ScanSummary[]; currentScanId?: string; onSelectScan: (summary: ScanSummary) => void }) {
|
||||
return <section className="section-card scan-runs-workspace"><div className="section-heading"><div><span className="eyebrow">Immutable history</span><h2>Scan runs</h2></div><span className="section-note">Select a revision to open the retained report.</span></div>{scanSummaries.length ? <div className="scan-run-list">{scanSummaries.map((item) => <button type="button" className={`scan-run-row ${item.id === currentScanId ? 'selected' : ''}`} key={item.id} onClick={() => onSelectScan(item)}><span><strong>{item.actual_revision ? item.actual_revision.slice(0, 10) : 'not acquired'}</strong><small>{new Date(item.scanned_at).toLocaleString()} · {item.trigger} · {item.findings} finding(s) · {Math.round(item.duration_ms)}ms</small></span><span className={`run-readiness ${item.release_readiness.toLowerCase().includes('block') ? 'blocked' : item.release_readiness.toLowerCase().includes('warning') ? 'warnings' : 'ready'}`}>{item.release_readiness}</span><strong>{item.health_score}</strong></button>)}</div> : <div className="empty-inline">Analyze this repository to create its immutable scan history.</div>}</section>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseRoute, routePath } from './navigation'
|
||||
|
||||
describe('client navigation', () => {
|
||||
it('round-trips repository identities and detail views', () => {
|
||||
const path = routePath({ page: 'repositories', repositoryId: 'gitea:64', view: 'findings' })
|
||||
expect(path).toBe('/repositories/gitea%3A64/findings')
|
||||
expect(parseRoute(path)).toEqual({ page: 'repositories', repositoryId: 'gitea:64', view: 'findings' })
|
||||
})
|
||||
|
||||
it('maps global destinations and safe repository defaults', () => {
|
||||
expect(parseRoute('/')).toEqual({ page: 'dashboard' })
|
||||
expect(routePath({ page: 'dashboard' })).toBe('/')
|
||||
expect(parseRoute('/activity')).toEqual({ page: 'activity' })
|
||||
expect(parseRoute('/repositories')).toEqual({ page: 'repositories' })
|
||||
expect(parseRoute('/repositories/gitea%3A64/unknown')).toEqual({ page: 'repositories', repositoryId: 'gitea:64', view: 'overview' })
|
||||
expect(routePath({ page: 'settings' })).toBe('/settings')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
export type DetailView = 'overview' | 'architecture' | 'hotspots' | 'dependencies' | 'findings' | 'history' | 'scans'
|
||||
export type Route = { page: 'dashboard' | 'repositories' | 'activity' | 'queue' | 'settings'; repositoryId?: string; view?: DetailView }
|
||||
|
||||
export const DETAIL_VIEWS: DetailView[] = ['overview', 'findings', 'architecture', 'hotspots', 'dependencies', 'history', 'scans']
|
||||
|
||||
export function parseRoute(pathname: string): Route {
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
if (!segments.length || segments[0] === 'dashboard') return { page: 'dashboard' }
|
||||
if (segments[0] === 'activity') return { page: 'activity' }
|
||||
if (segments[0] === 'queue') return { page: 'queue' }
|
||||
if (segments[0] === 'settings') return { page: 'settings' }
|
||||
if (segments[0] === 'repositories' && segments[1]) {
|
||||
let repositoryId = segments[1]
|
||||
try {
|
||||
repositoryId = decodeURIComponent(repositoryId)
|
||||
} catch {
|
||||
// Keep the raw segment in the not-found state if a malformed URL is supplied.
|
||||
}
|
||||
const view = DETAIL_VIEWS.includes(segments[2] as DetailView) ? segments[2] as DetailView : 'overview'
|
||||
return { page: 'repositories', repositoryId, view }
|
||||
}
|
||||
return { page: 'repositories' }
|
||||
}
|
||||
|
||||
export function routePath(route: Route): string {
|
||||
if (route.page === 'dashboard') return '/'
|
||||
if (route.page === 'activity') return '/activity'
|
||||
if (route.page === 'queue') return '/queue'
|
||||
if (route.page === 'settings') return '/settings'
|
||||
if (route.repositoryId) return `/repositories/${encodeURIComponent(route.repositoryId)}/${route.view ?? 'overview'}`
|
||||
return '/repositories'
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #e8eff8;
|
||||
background: #070d16;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; background: #070d16; }
|
||||
body { min-width: 320px; min-height: 100vh; margin: 0; background: radial-gradient(circle at 12% -10%, rgba(42, 76, 112, .24), transparent 34%), #070d16; }
|
||||
body, button, input, select { font: inherit; }
|
||||
button, a, input, select { -webkit-tap-highlight-color: transparent; }
|
||||
a { color: #9fc4e7; text-decoration: none; }
|
||||
a:hover { color: #d9ecff; }
|
||||
button { border: 1px solid #315272; border-radius: 8px; padding: 9px 13px; color: #ecf5ff; background: #173553; cursor: pointer; transition: border-color .16s ease, background .16s ease, transform .16s ease; }
|
||||
button:hover:not(:disabled) { border-color: #6d99c3; background: #1d4164; }
|
||||
button:active:not(:disabled) { transform: translateY(1px); }
|
||||
button:disabled { cursor: wait; opacity: .55; }
|
||||
button.ghost { background: #0c1726; }
|
||||
button.text-button { border: 0; padding: 5px 0; color: #9cc2e8; background: transparent; }
|
||||
button.text-button:hover:not(:disabled) { color: #e3f1ff; background: transparent; }
|
||||
input, select { width: 100%; min-height: 38px; border: 1px solid #294661; border-radius: 8px; padding: 8px 10px; color: #eef6ff; background: #0b1625; outline: none; }
|
||||
input::placeholder { color: #627891; }
|
||||
input:focus, select:focus, button:focus-visible, a:focus-visible, summary:focus-visible { border-color: #80b2df; outline: 3px solid rgba(91, 157, 210, .23); outline-offset: 1px; }
|
||||
code { color: #b8d7f1; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: .88em; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1, h2, h3 { color: #eff6ff; }
|
||||
h1 { margin-bottom: 8px; font-size: clamp(28px, 3vw, 38px); letter-spacing: -.035em; line-height: 1.05; }
|
||||
h2 { margin-bottom: 8px; font-size: clamp(20px, 2vw, 27px); letter-spacing: -.025em; }
|
||||
h3 { margin-bottom: 7px; font-size: 17px; letter-spacing: -.015em; }
|
||||
.muted { max-width: 72ch; margin-bottom: 0; color: #8da2b9; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.eyebrow { color: #82a5c8; font-size: 10px; font-weight: 650; letter-spacing: .13em; text-transform: uppercase; }
|
||||
kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; border: 1px solid #35506d; border-bottom-color: #213a53; border-radius: 5px; padding: 2px 5px; color: #9fb8d1; background: #0a1523; font-family: inherit; font-size: 10px; line-height: 1.2; }
|
||||
|
||||
.app-shell { min-height: 100vh; }
|
||||
.app-header { position: sticky; z-index: 20; top: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 26px; width: min(100% - 48px, 1600px); min-height: 68px; margin: 0 auto; border-bottom: 1px solid rgba(49, 76, 104, .56); background: rgba(7, 13, 22, .9); backdrop-filter: blur(18px); }
|
||||
.brand-lockup { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
|
||||
.brand-lockup > a { display: flex; align-items: center; gap: 9px; color: #f1f7ff; }
|
||||
.brand-lockup > a > span:last-child { display: grid; gap: 1px; }
|
||||
.brand-lockup strong { font-size: 16px; letter-spacing: -.02em; }
|
||||
.brand-lockup small { color: #718aa4; font-size: 9px; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.brand-mark { display: grid; width: 29px; height: 29px; place-items: center; border: 1px solid #4d78a1; border-radius: 8px; color: #d9edff; background: linear-gradient(145deg, #1c4b72, #11243a); font-size: 10px; font-weight: 800; letter-spacing: .04em; }
|
||||
.version-badge { border: 1px solid #294861; border-radius: 99px; padding: 4px 7px; color: #87abc9; background: #0c1b2c; font-size: 9px; font-weight: 700; letter-spacing: .07em; }
|
||||
.global-nav { display: flex; align-items: center; align-self: stretch; gap: 4px; min-width: 0; }
|
||||
.global-nav a { display: inline-flex; align-items: center; gap: 7px; align-self: stretch; border-bottom: 2px solid transparent; padding: 0 11px; color: #8ba1b8; font-size: 12px; white-space: nowrap; }
|
||||
.global-nav a span { color: #6f8aa6; font-size: 10px; }
|
||||
.global-nav a:hover { color: #e4f2ff; }
|
||||
.global-nav a.active { border-bottom-color: #62b2d0; color: #f2f7fd; background: linear-gradient(180deg, transparent, rgba(55, 112, 145, .11)); }
|
||||
.header-actions { display: flex; align-items: center; gap: 7px; }
|
||||
.shortcut-button { display: inline-flex; align-items: center; gap: 12px; border-color: #294560; padding: 7px 8px 7px 11px; color: #a8bed5; background: #0b1625; font-size: 11px; }
|
||||
.shortcut-button kbd { margin-left: 2px; }
|
||||
.icon-button { display: grid; width: 35px; height: 35px; place-items: center; padding: 0; border-color: #294560; color: #a5c2dc; background: #0b1625; font-size: 18px; line-height: 1; }
|
||||
.sync-icon { font-size: 21px; }
|
||||
|
||||
.page-content { width: min(100% - 48px, 1600px); margin: 0 auto; padding: 29px 0 60px; }
|
||||
.page-heading { display: flex; align-items: end; justify-content: space-between; gap: 28px; margin-bottom: 22px; }
|
||||
.page-heading h1 { display: flex; align-items: baseline; gap: 10px; }
|
||||
.page-heading-actions { display: flex; flex-wrap: wrap; justify-content: end; gap: 8px; }
|
||||
.count-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; height: 24px; border: 1px solid #31516d; border-radius: 6px; color: #9dc0df; background: #0e2238; font-size: 12px; letter-spacing: 0; }
|
||||
.global-banner { width: min(100% - 48px, 1600px); margin: 17px auto -3px; }
|
||||
.error-banner, .notice-banner { border-radius: 8px; padding: 10px 12px; font-size: 12px; line-height: 1.45; }
|
||||
.error-banner { border: 1px solid #713a49; color: #ffdce2; background: #2d141d; }
|
||||
.notice-banner { border: 1px solid #2d625a; color: #c8f0e1; background: #0e2927; }
|
||||
.positive { color: #77e0a6 !important; }
|
||||
.negative { color: #ff9ba8 !important; }
|
||||
|
||||
.section-card { min-width: 0; border: 1px solid #21384f; border-radius: 11px; padding: 18px; background: rgba(11, 22, 37, .94); box-shadow: 0 14px 42px rgba(0, 0, 0, .12); }
|
||||
.catalog-toolbar { display: grid; gap: 14px; margin-bottom: 14px; }
|
||||
.search-control, .switcher-search { display: flex; align-items: center; gap: 8px; min-width: 0; border: 1px solid #294863; border-radius: 8px; padding: 0 8px 0 11px; background: #091522; }
|
||||
.search-control:focus-within, .switcher-search:focus-within { border-color: #6f9fc8; box-shadow: 0 0 0 3px rgba(83, 142, 190, .12); }
|
||||
.search-control input, .switcher-search input { min-height: 42px; border: 0; padding-left: 0; background: transparent; box-shadow: none; }
|
||||
.search-glyph { color: #78a0c1; font-size: 20px; line-height: 1; }
|
||||
.search-control kbd { flex: 0 0 auto; }
|
||||
.filter-grid { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: 9px; }
|
||||
.filter-grid label, .pagination-controls label { display: grid; gap: 6px; color: #7791ab; font-size: 10px; }
|
||||
.filter-grid select { min-height: 35px; padding: 6px 8px; font-size: 12px; }
|
||||
.toolbar-footer, .pagination-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #7992ac; font-size: 11px; }
|
||||
.filter-result { color: #9bb2c8; }
|
||||
.catalog-table-card { padding: 0; overflow: hidden; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.repository-table { width: 100%; min-width: 890px; border-collapse: collapse; text-align: left; }
|
||||
.repository-table th { border-bottom: 1px solid #254159; padding: 13px 16px; color: #7590aa; font-size: 10px; font-weight: 650; letter-spacing: .09em; text-transform: uppercase; white-space: nowrap; }
|
||||
.repository-table td { border-bottom: 1px solid rgba(35, 58, 80, .7); padding: 13px 16px; color: #b3c5d8; font-size: 12px; vertical-align: middle; }
|
||||
.repository-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.repository-table tbody tr:hover { background: rgba(26, 57, 87, .22); }
|
||||
.sort-button { display: inline-flex; align-items: center; gap: 7px; border: 0; padding: 0; color: inherit; background: transparent; font-size: inherit; font-weight: inherit; letter-spacing: inherit; text-transform: inherit; }
|
||||
.sort-button:hover:not(:disabled) { border: 0; color: #d8eafb; background: transparent; }
|
||||
.sort-button span { color: #83b6d4; font-size: 13px; }
|
||||
.repo-cell { display: flex; align-items: center; gap: 10px; min-width: 235px; }
|
||||
.repo-cell > div:last-child { min-width: 0; }
|
||||
.repo-link { display: block; max-width: 330px; overflow: hidden; border: 0; padding: 0; color: #e8f2fc; background: transparent; font-size: 13px; font-weight: 650; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.repo-link:hover:not(:disabled) { border: 0; color: #9cdbf5; background: transparent; }
|
||||
.repo-cell small, .table-date small { display: block; margin-top: 4px; color: #718aa4; font-size: 10px; }
|
||||
.star-button { display: grid; flex: 0 0 auto; width: 27px; height: 27px; place-items: center; border: 0; padding: 0; color: #5f7893; background: transparent; font-size: 18px; line-height: 1; }
|
||||
.star-button:hover:not(:disabled) { border: 0; color: #f0ce7b; background: transparent; }
|
||||
.star-button.pinned { color: #f0c86f; }
|
||||
.status-label, .readiness-label { display: inline-flex; align-items: center; gap: 7px; color: #9bafc4; font-size: 11px; white-space: nowrap; }
|
||||
.status-label i, .readiness-label i, .activity-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #71859b; box-shadow: 0 0 0 3px rgba(113, 133, 155, .08); }
|
||||
.status-current i, .status-label.status-current i { background: #64d3a0; box-shadow: 0 0 0 3px rgba(100, 211, 160, .1); }
|
||||
.status-scanning i, .status-queued i, .status-stale i { background: #e6b766; box-shadow: 0 0 0 3px rgba(230, 183, 102, .1); }
|
||||
.status-failed i, .status-unavailable i { background: #e77f8e; box-shadow: 0 0 0 3px rgba(231, 127, 142, .1); }
|
||||
.readiness-ready i { background: #64d3a0; }
|
||||
.readiness-warnings i { background: #e6b766; }
|
||||
.readiness-blocked i { background: #e77f8e; }
|
||||
.health-value { color: #d4e8fa; font-size: 14px; }
|
||||
.table-date { color: #b7cadc; white-space: nowrap; }
|
||||
.table-action { padding: 6px 9px; border-color: #2c4c69; color: #a6c6e1; background: #0f2237; font-size: 11px; }
|
||||
.pagination-bar { border-top: 1px solid #21384f; padding: 12px 16px; }
|
||||
.pagination-controls { display: flex; align-items: center; gap: 9px; }
|
||||
.pagination-controls label { display: flex; align-items: center; grid-template-columns: auto auto; gap: 6px; }
|
||||
.pagination-controls select { width: auto; min-height: 30px; padding: 4px 7px; font-size: 11px; }
|
||||
.pagination-controls button { padding: 6px 9px; font-size: 11px; }
|
||||
.page-number { min-width: 74px; color: #a1b8ce; text-align: center; }
|
||||
|
||||
.loading-state { display: flex; align-items: center; gap: 10px; min-height: 130px; color: #9db6cc; font-size: 13px; }
|
||||
.loading-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #68c8ca; box-shadow: 0 0 0 5px rgba(104, 200, 202, .1); animation: pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: .35; transform: scale(.72); } }
|
||||
.empty-state { display: grid; justify-items: start; align-content: center; min-height: 260px; padding: 34px; }
|
||||
.empty-state h1, .empty-state h2 { max-width: 620px; }
|
||||
.empty-state .muted { margin-bottom: 17px; }
|
||||
.empty-icon { display: grid; width: 36px; height: 36px; place-items: center; margin-bottom: 15px; border: 1px solid #345474; border-radius: 9px; color: #91c2df; background: #102b42; font-size: 20px; }
|
||||
.button-link { display: inline-flex; align-items: center; min-height: 38px; border: 1px solid #315272; border-radius: 8px; padding: 8px 13px; color: #ecf5ff; background: #173553; }
|
||||
.button-link:hover { border-color: #6d99c3; color: #fff; background: #1d4164; }
|
||||
.compact-empty { min-height: 180px; padding: 22px 0; }
|
||||
|
||||
.breadcrumb-row { display: flex; align-items: center; gap: 9px; margin-bottom: 11px; color: #6f89a2; font-size: 11px; }
|
||||
.breadcrumb-row button { border: 0; padding: 0; color: #b8cce0; background: transparent; font-size: 11px; }
|
||||
.breadcrumb-row button:hover:not(:disabled) { border: 0; color: #eff7ff; background: transparent; }
|
||||
.detail-header { display: grid; grid-template-columns: minmax(300px, 1.35fr) minmax(270px, 1fr) auto auto; align-items: center; gap: 19px; min-height: 112px; padding: 16px 18px; }
|
||||
.detail-identity { display: flex; align-items: center; min-width: 0; gap: 12px; }
|
||||
.detail-identity > div:last-child { min-width: 0; }
|
||||
.provider-mark { display: grid; flex: 0 0 27px; width: 27px; height: 27px; place-items: center; border: 1px solid #36597e; border-radius: 7px; color: #b8d7f2; background: #10243b; font-size: 11px; font-weight: 750; }
|
||||
.provider-mark.large { width: 36px; height: 36px; border-radius: 9px; font-size: 13px; }
|
||||
.detail-header h1 { margin: 3px 0 4px; overflow: hidden; font-size: clamp(20px, 2.2vw, 29px); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-description { max-width: 52ch; margin: 0; overflow: hidden; color: #8098b2; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-meta { display: flex; flex-wrap: wrap; gap: 10px 16px; }
|
||||
.detail-meta span { display: grid; gap: 4px; color: #c2d3e2; font-size: 11px; }
|
||||
.detail-meta strong { color: #718ca7; font-size: 9px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.detail-status { display: grid; justify-items: end; gap: 3px; min-width: 102px; }
|
||||
.detail-status > strong { color: #ebf5fd; font-size: 18px; letter-spacing: -.02em; }
|
||||
.detail-status small { max-width: 130px; overflow: hidden; color: #7f96ad; font-size: 9px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-actions { display: flex; align-items: center; justify-content: end; gap: 6px; }
|
||||
.detail-actions > button:first-child { min-width: 77px; }
|
||||
.action-menu { position: relative; }
|
||||
.action-menu summary { list-style: none; border: 1px solid #315272; border-radius: 8px; padding: 9px 11px; color: #d7e8f8; background: #0e2339; cursor: pointer; font-size: 12px; }
|
||||
.action-menu summary::-webkit-details-marker { display: none; }
|
||||
.action-menu summary::after { display: inline-block; margin-left: 7px; color: #7398b8; content: '⌄'; }
|
||||
.action-menu[open] summary { border-color: #79a6cf; }
|
||||
.menu-popover { position: absolute; z-index: 4; top: calc(100% + 7px); right: 0; display: grid; min-width: 155px; padding: 5px; border: 1px solid #31516c; border-radius: 8px; background: #0c1b2b; box-shadow: 0 15px 34px rgba(0, 0, 0, .35); }
|
||||
.menu-popover button { border: 0; border-radius: 5px; padding: 8px 9px; color: #b9cde0; background: transparent; text-align: left; font-size: 11px; }
|
||||
.menu-popover button:hover:not(:disabled) { border: 0; color: #eff7ff; background: #173653; }
|
||||
.header-star { width: 32px; height: 35px; border: 1px solid #294560; border-radius: 8px; background: #0b1625; }
|
||||
.header-star:hover:not(:disabled) { border: 1px solid #6d99c3; background: #173653; }
|
||||
.detail-tabs { display: flex; gap: 3px; margin: 14px 0; border-bottom: 1px solid #20384f; overflow-x: auto; }
|
||||
.detail-tabs a { position: relative; display: inline-flex; align-items: center; min-height: 38px; border-bottom: 2px solid transparent; padding: 0 12px; color: #7f96ad; font-size: 12px; white-space: nowrap; }
|
||||
.detail-tabs a:hover { color: #dcecfb; }
|
||||
.detail-tabs a.active { border-bottom-color: #6ab9d0; color: #eff7ff; }
|
||||
.advanced-info { display: grid; grid-template-columns: 220px minmax(0, 1fr); align-items: center; gap: 25px; margin-bottom: 14px; }
|
||||
.advanced-info h2 { margin: 5px 0 0; font-size: 19px; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, minmax(120px, 1fr)); gap: 13px 20px; }
|
||||
.info-grid span, .settings-list div { display: grid; gap: 4px; color: #bed0e0; font-size: 11px; }
|
||||
.info-grid strong, .settings-list dt { color: #7089a3; font-size: 9px; font-weight: 650; letter-spacing: .07em; text-transform: uppercase; }
|
||||
.detail-content { min-width: 0; }
|
||||
.job-progress { display: grid; gap: 9px; margin-top: 12px; border: 1px solid #315875; border-radius: 9px; padding: 11px 14px; background: #0b1d2d; }
|
||||
.job-progress > div:first-child { display: flex; justify-content: space-between; gap: 12px; color: #cde0f1; font-size: 12px; }
|
||||
.job-progress > div:first-child span { color: #85a0ba; text-transform: capitalize; }
|
||||
.progress-track { height: 5px; overflow: hidden; border-radius: 99px; background: #1b3147; }
|
||||
.progress-track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #64c6d1, #78daa2); transition: width .25s ease; }
|
||||
.repository-facts { display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0; }
|
||||
.repository-facts span { border: 1px solid #284a67; border-radius: 6px; padding: 7px 9px; color: #b2c9df; background: #0b1c2d; font-size: 11px; }
|
||||
.repository-empty { min-height: 330px; justify-items: start; }
|
||||
|
||||
.view-stack { display: grid; gap: 14px; min-width: 0; }
|
||||
.low-signal-notice { margin: 0; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
|
||||
.metric-grid article { min-width: 0; border: 1px solid #203a53; border-radius: 9px; padding: 13px; background: #0a1727; }
|
||||
.metric-grid.low-signal article { border-color: transparent; padding: 9px 10px; background: transparent; }
|
||||
.metric-grid span { display: block; min-height: 27px; color: #87a0b8; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.metric-grid strong { display: block; margin-top: 7px; color: #e9f4fe; font-size: 24px; letter-spacing: -.03em; }
|
||||
.metric-grid.low-signal strong { color: #a5b9cb; font-size: 21px; }
|
||||
.metric-grid small { display: block; min-height: 25px; margin-top: 4px; color: #718aa4; font-size: 9px; line-height: 1.35; }
|
||||
.insight-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; }
|
||||
.insight-card { min-width: 0; border-left: 3px solid #3d6688; border-top: 1px solid #213b55; border-right: 1px solid #213b55; border-bottom: 1px solid #213b55; border-radius: 8px; padding: 14px 15px; background: #0b1929; }
|
||||
.insight-card.insight-ready { border-left-color: #65d3a0; }
|
||||
.insight-card.insight-warnings { border-left-color: #e6b766; }
|
||||
.insight-card.insight-blocked { border-left-color: #e77f8e; }
|
||||
.insight-card strong { display: block; margin: 6px 0 4px; overflow: hidden; color: #e3f1fc; font-size: 14px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.insight-card p { margin: 0; overflow: hidden; color: #8199b1; font-size: 11px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.overview-columns { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); gap: 14px; }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 15px; }
|
||||
.section-heading h3 { margin: 5px 0 0; }
|
||||
.section-note { max-width: 39ch; color: #7890a9; font-size: 10px; line-height: 1.45; text-align: right; }
|
||||
.delta { border-radius: 5px; padding: 5px 8px; color: #a9bfd3; background: #0a1827; font-size: 10px; white-space: nowrap; }
|
||||
.score-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.score-card { min-width: 0; border: 1px solid #203a53; border-radius: 8px; padding: 12px; background: #0a1726; }
|
||||
.score-card > div:first-child, .language > div:first-child { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.score-card span, .language span { color: #a0b4c9; font-size: 11px; }
|
||||
.score-card strong { color: #e4f0fb; font-size: 20px; }
|
||||
.score-card small { display: block; margin-top: 7px; color: #859cb4; font-size: 10px; }
|
||||
.bar { height: 5px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: #1b2b3e; }
|
||||
.bar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #5686b5, #7abfd0); }
|
||||
.release-gate-card { align-content: start; }
|
||||
.gate-list { display: grid; gap: 7px; margin-top: 15px; }
|
||||
.gate-check { display: grid; grid-template-columns: 50px minmax(0, 1fr); gap: 9px; align-items: start; border: 1px solid #20384f; border-radius: 7px; padding: 9px; background: #091522; }
|
||||
.gate-state { display: inline-flex; justify-content: center; border: 1px solid currentColor; border-radius: 5px; padding: 4px 5px; font-size: 9px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.gate-pass { color: #77dca3; }
|
||||
.gate-warn { color: #ecc16e; }
|
||||
.gate-block { color: #ff929f; }
|
||||
.gate-check strong { display: block; color: #dceaf7; font-size: 11px; }
|
||||
.gate-check small { display: block; margin-top: 4px; color: #7d94ad; font-size: 10px; line-height: 1.35; }
|
||||
.signals-columns { grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); }
|
||||
.language-list { display: grid; gap: 12px; margin-top: 17px; }
|
||||
.language strong { color: #dce7f5; font-size: 11px; }
|
||||
.signal-stack { display: grid; gap: 14px; }
|
||||
.signal-card h3 { margin-bottom: 5px; }
|
||||
.compact-dl { display: grid; gap: 8px; margin: 15px 0 0; }
|
||||
.compact-dl div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; border-bottom: 1px solid rgba(44, 63, 86, .44); padding-bottom: 6px; }
|
||||
.compact-dl dt { color: #8298b2; font-size: 11px; }
|
||||
.compact-dl dd { margin: 0; color: #d9e7f4; font-size: 11px; text-align: right; }
|
||||
.empty-inline { padding: 16px 0 6px; color: #8095ac; font-size: 11px; line-height: 1.45; }
|
||||
|
||||
.finding-filters { display: grid; grid-template-columns: minmax(220px, 1fr) 150px 180px; gap: 8px; margin-bottom: 13px; }
|
||||
.finding-filters input, .finding-filters select { width: 100%; }
|
||||
.findings-list, .hotspot-list, .scan-run-list { display: grid; gap: 7px; }
|
||||
.finding { display: block; overflow: hidden; border: 1px solid #20384f; border-radius: 8px; background: #091522; }
|
||||
.finding-summary { display: grid; grid-template-columns: 69px minmax(0, 1fr) 42px 14px; align-items: start; gap: 11px; padding: 11px; cursor: pointer; list-style: none; }
|
||||
.finding-summary::-webkit-details-marker { display: none; }
|
||||
.finding-summary::after { align-self: center; color: #7f9ab4; content: '+'; font-size: 15px; line-height: 1; text-align: right; }
|
||||
.finding[open] .finding-summary::after { content: '−'; }
|
||||
.finding-copy { min-width: 0; }
|
||||
.finding-copy strong, .finding-copy > span { display: block; }
|
||||
.finding strong { color: #e3effa; font-size: 12px; font-weight: 600; line-height: 1.35; }
|
||||
.finding-copy > span { margin-top: 4px; color: #7f94ae; font-size: 10px; overflow-wrap: anywhere; }
|
||||
.severity { display: inline-flex; justify-content: center; border: 1px solid currentColor; border-radius: 5px; padding: 4px 5px; font-size: 9px; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.severity-blocker { color: #ff909b; }
|
||||
.severity-warning { color: #efc16e; }
|
||||
.severity-info { color: #91acc7; }
|
||||
.deduction { justify-self: end; color: #90a5be; font-size: 10px; }
|
||||
.finding-details { border-top: 1px solid #20384f; padding: 12px 13px 13px 93px; background: #0b1a2a; }
|
||||
.finding-detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px 18px; margin: 0; }
|
||||
.finding-detail-grid div { display: grid; min-width: 0; gap: 4px; }
|
||||
.finding-detail-grid dt { color: #718ba5; font-size: 9px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.finding-detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #c4d5e4; font-size: 10px; line-height: 1.35; }
|
||||
.finding.suppressed { opacity: .64; }
|
||||
.finding.suppressed .severity { color: #b8a36b; border-color: #66552d; background: #2b2412; }
|
||||
.hotspot-row { display: grid; grid-template-columns: 37px minmax(0, 1fr) 100px; gap: 12px; align-items: center; border: 1px solid #20384f; border-radius: 8px; padding: 11px; background: #091522; }
|
||||
.hotspot-rank { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 7px; color: #9bb5ce; background: #13283e; font-size: 12px; font-weight: 700; }
|
||||
.hotspot-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.hotspot-title strong { overflow-wrap: anywhere; color: #e1edf8; font-size: 12px; }
|
||||
.hotspot-title span { color: #8197b0; font-size: 10px; }
|
||||
.hotspot-reasons { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
|
||||
.hotspot-reasons span { border-radius: 5px; padding: 3px 5px; color: #8da3bc; background: #102137; font-size: 9px; }
|
||||
.hotspot-bar i { background: linear-gradient(90deg, #628fc0, #c4766f); }
|
||||
.hotspot-metrics { display: grid; justify-items: end; gap: 2px; }
|
||||
.hotspot-metrics strong { color: #e2effa; font-size: 23px; }
|
||||
.hotspot-metrics small { color: #7f95ae; font-size: 9px; }
|
||||
|
||||
.architecture-summary { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
|
||||
.architecture-stats { display: grid; grid-template-columns: repeat(3, 85px); gap: 7px; }
|
||||
.architecture-stats div { border: 1px solid #203b58; border-radius: 8px; padding: 10px; background: #091624; }
|
||||
.architecture-stats strong { display: block; color: #e8f4fe; font-size: 21px; }
|
||||
.architecture-stats strong small { color: #6f89a6; font-size: 11px; font-weight: 500; }
|
||||
.architecture-stats span { color: #8196af; font-size: 9px; line-height: 1.2; text-transform: uppercase; }
|
||||
.architecture-canvas { height: 650px; padding: 0; overflow: hidden; }
|
||||
.react-flow__node { min-width: 150px; border: 1px solid #36587e !important; border-radius: 8px !important; padding: 10px 12px !important; color: #edf6ff !important; background: #10233a !important; box-shadow: 0 10px 28px rgba(0, 0, 0, .25); font-size: 11px; }
|
||||
.react-flow__node.arch-hot { border-color: #a24b59 !important; }
|
||||
.react-flow__node.arch-warm { border-color: #9b7634 !important; }
|
||||
.react-flow__node.arch-calm { border-color: #37638a !important; }
|
||||
.react-flow__edge-path { stroke: #668ab3; stroke-width: 1.7; }
|
||||
.react-flow__controls { overflow: hidden; border: 1px solid #274563; border-radius: 8px; }
|
||||
.react-flow__controls button { border-radius: 0; color: #fff; background: #10233a; }
|
||||
.react-flow__minimap { overflow: hidden; border: 1px solid #274563; border-radius: 8px; background: #08131f !important; }
|
||||
.trend-chart { display: flex; align-items: end; gap: 7px; min-height: 250px; overflow-x: auto; padding: 18px 6px 1px; }
|
||||
.trend-column { display: grid; flex: 1 0 40px; grid-template-rows: 19px 180px 30px; gap: 5px; min-width: 40px; max-width: 70px; text-align: center; }
|
||||
.trend-column > span { color: #9fb2c8; font-size: 10px; }
|
||||
.trend-track { display: flex; align-items: end; overflow: hidden; border-radius: 6px; background: #0a1726; }
|
||||
.trend-track i { display: block; width: 100%; border-radius: 6px 6px 0 0; background: linear-gradient(180deg, #6d9bc8, #365d84); }
|
||||
.trend-column small { color: #748aa4; font-size: 9px; line-height: 1.2; }
|
||||
.comparison-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.delta-grid { display: grid; gap: 7px; margin-top: 14px; }
|
||||
.delta-grid div { display: flex; justify-content: space-between; gap: 12px; border-bottom: 1px solid rgba(44, 63, 86, .45); padding-bottom: 6px; }
|
||||
.delta-grid span { color: #8499b2; font-size: 11px; }
|
||||
.delta-grid strong { font-size: 11px; }
|
||||
.delta-findings { display: grid; gap: 7px; margin-top: 14px; }
|
||||
.delta-findings > div { border-radius: 6px; padding: 8px 9px; font-size: 10px; line-height: 1.4; }
|
||||
.delta-findings .resolved { color: #a5dac3; background: #102922; }
|
||||
.delta-findings .added { color: #efacb4; background: #2d171c; }
|
||||
.scan-run-card { margin-top: 0; }
|
||||
.scan-run-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border-color: #20384f; border-radius: 7px; padding: 10px 11px; color: #d8e7f3; background: #091522; text-align: left; }
|
||||
.scan-run-row:hover:not(:disabled), .scan-run-row.selected { border-color: #5684ad; background: #122a42; }
|
||||
.scan-run-row span { min-width: 0; }
|
||||
.scan-run-row span strong, .scan-run-row span small { display: block; }
|
||||
.scan-run-row span strong { font-size: 12px; }
|
||||
.scan-run-row span small { margin-top: 3px; color: #7e94ab; font-size: 10px; }
|
||||
.scan-run-row > strong { font-size: 18px; }
|
||||
|
||||
.summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-bottom: 14px; }
|
||||
.summary-strip > div { border: 1px solid #20384f; border-radius: 8px; padding: 13px; background: #0a1726; }
|
||||
.summary-strip span { display: block; color: #7c95ad; font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.summary-strip strong { display: block; margin-top: 6px; color: #e5f1fb; font-size: 21px; letter-spacing: -.02em; }
|
||||
.activity-card, .queue-card { min-height: 280px; }
|
||||
.activity-list, .queue-list { display: grid; gap: 5px; }
|
||||
.activity-row, .queue-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto auto; align-items: center; gap: 11px; width: 100%; border-color: #20384f; border-radius: 7px; padding: 11px 12px; background: #091522; text-align: left; }
|
||||
.activity-row:hover:not(:disabled) { border-color: #5684ad; background: #122a42; }
|
||||
.activity-row > span:nth-child(2) { min-width: 0; }
|
||||
.activity-row strong, .queue-row strong { display: block; overflow: hidden; color: #dbeaf6; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-row small, .queue-row small { display: block; margin-top: 3px; color: #7e94ab; font-size: 10px; }
|
||||
.activity-health { color: #d9eafb !important; font-size: 15px !important; }
|
||||
.activity-dot { width: 7px; height: 7px; }
|
||||
.row-chevron { color: #7190ac; font-size: 16px; }
|
||||
.queue-row { grid-template-columns: 110px minmax(0, 1fr) auto; }
|
||||
.queue-row > span:nth-child(2) { min-width: 0; }
|
||||
|
||||
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.settings-grid h2, .settings-note h2 { margin-top: 6px; font-size: 20px; }
|
||||
.settings-list { display: grid; gap: 11px; margin: 22px 0; }
|
||||
.settings-list div { display: flex; align-items: baseline; justify-content: space-between; gap: 15px; border-bottom: 1px solid rgba(44, 63, 86, .45); padding-bottom: 8px; }
|
||||
.settings-list dd { margin: 0; color: #d3e2ef; font-size: 11px; text-align: right; }
|
||||
.settings-list .status-label { font-size: 11px; }
|
||||
.local-form { display: flex; align-items: end; gap: 8px; margin-top: 20px; }
|
||||
.local-form label { display: grid; flex: 1; gap: 6px; color: #91a8c0; font-size: 10px; }
|
||||
.local-form button { white-space: nowrap; }
|
||||
.local-result { display: grid; gap: 4px; margin-top: 17px; border-left: 3px solid #65d3a0; border-radius: 6px; padding: 10px 11px; color: #a9c0d5; background: #0b2022; font-size: 11px; }
|
||||
.local-result strong { color: #e4f3fb; }
|
||||
.settings-note { margin-top: 14px; }
|
||||
.settings-counts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 17px; }
|
||||
.settings-counts span { border: 1px solid #294861; border-radius: 6px; padding: 7px 9px; color: #91aac2; font-size: 11px; }
|
||||
.settings-counts strong { color: #dff0fc; }
|
||||
|
||||
.modal-backdrop { position: fixed; z-index: 40; inset: 0; display: grid; place-items: start center; padding: min(17vh, 150px) 20px 20px; background: rgba(2, 6, 11, .72); backdrop-filter: blur(5px); }
|
||||
.switcher-modal { width: min(620px, 100%); overflow: hidden; border: 1px solid #3c607d; border-radius: 11px; background: #0b1828; box-shadow: 0 25px 80px rgba(0, 0, 0, .5); }
|
||||
.switcher-heading { display: flex; align-items: start; justify-content: space-between; gap: 15px; padding: 18px 18px 13px; }
|
||||
.switcher-heading h2 { margin: 5px 0 0; font-size: 22px; }
|
||||
.switcher-search { margin: 0 15px 11px; }
|
||||
.switcher-results { display: grid; max-height: min(54vh, 440px); overflow-y: auto; padding: 0 7px 7px; }
|
||||
.switcher-item { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; border: 1px solid transparent; border-radius: 7px; padding: 10px 9px; color: #dceaf6; background: transparent; text-align: left; }
|
||||
.switcher-item:hover:not(:disabled), .switcher-item.active { border-color: #365e7f; background: #15314b; }
|
||||
.switcher-item > span:nth-child(2) { min-width: 0; }
|
||||
.switcher-item strong, .switcher-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.switcher-item strong { font-size: 12px; }
|
||||
.switcher-item small { margin-top: 3px; color: #839ab2; font-size: 10px; }
|
||||
.switcher-star { color: #607990; font-size: 16px; text-align: center; }
|
||||
.switcher-star.pinned { color: #f0c86f; }
|
||||
.switcher-footer { display: flex; flex-wrap: wrap; gap: 15px; border-top: 1px solid #20384f; padding: 11px 17px; color: #7892ab; font-size: 10px; }
|
||||
.switcher-footer span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
|
||||
.sr-only { position: absolute; left: -9999px; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.app-header { grid-template-columns: auto 1fr; gap: 17px; }
|
||||
.global-nav { grid-column: 1 / -1; grid-row: 2; min-height: 38px; border-top: 1px solid rgba(49, 76, 104, .42); overflow-x: auto; }
|
||||
.global-nav a { min-height: 38px; }
|
||||
.header-actions { grid-column: 2; grid-row: 1; justify-content: end; }
|
||||
.app-header { padding-top: 4px; }
|
||||
.metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.detail-header { grid-template-columns: minmax(250px, 1.4fr) minmax(250px, 1fr) auto; }
|
||||
.detail-actions { grid-column: 1 / -1; justify-content: start; }
|
||||
.detail-status { justify-items: start; }
|
||||
.detail-status small { text-align: left; }
|
||||
.filter-grid { grid-template-columns: repeat(3, minmax(140px, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-heading { align-items: start; flex-direction: column; }
|
||||
.page-heading-actions { justify-content: start; }
|
||||
.detail-header { grid-template-columns: 1fr 1fr; }
|
||||
.detail-identity { grid-column: 1 / -1; }
|
||||
.detail-meta { align-content: start; }
|
||||
.detail-status { align-self: start; }
|
||||
.detail-actions { grid-column: 1 / -1; }
|
||||
.overview-columns, .signals-columns { grid-template-columns: 1fr; }
|
||||
.advanced-info { grid-template-columns: 1fr; gap: 14px; }
|
||||
.info-grid { grid-template-columns: repeat(3, minmax(110px, 1fr)); }
|
||||
.summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.app-header, .page-content, .global-banner { width: min(100% - 24px, 1600px); }
|
||||
.app-header { min-height: 60px; gap: 10px; }
|
||||
.brand-lockup small, .shortcut-button span { display: none; }
|
||||
.brand-lockup { gap: 6px; }
|
||||
.shortcut-button { padding: 7px; }
|
||||
.global-nav { gap: 0; }
|
||||
.global-nav a { padding: 0 9px; font-size: 11px; }
|
||||
.page-content { padding-top: 22px; }
|
||||
.page-heading { margin-bottom: 17px; }
|
||||
.page-heading-actions { width: 100%; }
|
||||
.page-heading-actions button { flex: 1; }
|
||||
.section-card { padding: 14px; }
|
||||
.filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.toolbar-footer, .pagination-bar { align-items: start; flex-direction: column; }
|
||||
.pagination-controls { width: 100%; flex-wrap: wrap; }
|
||||
.pagination-controls label { margin-right: auto; }
|
||||
.repository-table th, .repository-table td { padding-right: 12px; padding-left: 12px; }
|
||||
.detail-header { grid-template-columns: 1fr; gap: 13px; }
|
||||
.detail-identity, .detail-actions { grid-column: auto; }
|
||||
.detail-status { justify-items: start; }
|
||||
.detail-status small { text-align: left; }
|
||||
.detail-actions { justify-content: start; flex-wrap: wrap; }
|
||||
.detail-actions > button:first-child { flex: 1; }
|
||||
.info-grid { grid-template-columns: repeat(2, minmax(110px, 1fr)); }
|
||||
.metric-grid, .insight-grid, .summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.metric-grid article { padding: 11px; }
|
||||
.metric-grid strong { font-size: 21px; }
|
||||
.insight-card { padding: 12px; }
|
||||
.insight-card strong { font-size: 12px; }
|
||||
.score-grid, .comparison-grid { grid-template-columns: 1fr; }
|
||||
.section-heading { align-items: start; flex-direction: column; gap: 7px; }
|
||||
.section-note { max-width: none; text-align: left; }
|
||||
.finding-filters { grid-template-columns: 1fr; }
|
||||
.finding-summary { grid-template-columns: 64px minmax(0, 1fr) 14px; }
|
||||
.finding-summary .deduction { display: none; }
|
||||
.finding-details { padding: 11px; }
|
||||
.finding-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.hotspot-row { grid-template-columns: 31px minmax(0, 1fr); }
|
||||
.hotspot-metrics { grid-column: 2; grid-template-columns: repeat(4, auto); align-items: baseline; justify-content: start; gap: 8px; }
|
||||
.hotspot-metrics strong { font-size: 18px; }
|
||||
.architecture-summary { align-items: start; flex-direction: column; }
|
||||
.architecture-stats { width: 100%; grid-template-columns: repeat(3, 1fr); }
|
||||
.architecture-canvas { height: 520px; }
|
||||
.activity-row { grid-template-columns: 10px minmax(0, 1fr) auto; }
|
||||
.row-chevron { display: none; }
|
||||
.queue-row { grid-template-columns: 1fr auto; }
|
||||
.queue-row > span:first-child { grid-column: 1 / -1; }
|
||||
.local-form { align-items: stretch; flex-direction: column; }
|
||||
.switcher-item { grid-template-columns: 22px minmax(0, 1fr); }
|
||||
.switcher-item > .status-label { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.version-badge { display: none; }
|
||||
.filter-grid, .metric-grid, .insight-grid, .summary-strip { grid-template-columns: 1fr; }
|
||||
.page-heading-actions { flex-direction: column; }
|
||||
.page-heading-actions button { flex: initial; width: 100%; }
|
||||
.detail-tabs a { padding: 0 9px; font-size: 11px; }
|
||||
.info-grid { grid-template-columns: 1fr; }
|
||||
.detail-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.detail-status { grid-row: auto; }
|
||||
.finding-detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* v1.2 signal-topology system ------------------------------------------------ */
|
||||
:root {
|
||||
--canvas: #07111c;
|
||||
--canvas-raised: #0a1624;
|
||||
--surface: #0d1b2b;
|
||||
--surface-strong: #122237;
|
||||
--surface-hover: #172a42;
|
||||
--border: rgba(132, 160, 193, .16);
|
||||
--border-strong: rgba(144, 189, 218, .34);
|
||||
--text: #eff7ff;
|
||||
--text-muted: #91a5bb;
|
||||
--text-subtle: #6e8299;
|
||||
--accent: #75d1cc;
|
||||
--accent-bright: #b1f0df;
|
||||
--ready: #64d39b;
|
||||
--warning: #e9bb71;
|
||||
--blocked: #ed7188;
|
||||
--radius-sm: 9px;
|
||||
--radius: 14px;
|
||||
--radius-lg: 20px;
|
||||
--shadow: 0 20px 55px rgba(0, 0, 0, .18);
|
||||
--ease: cubic-bezier(.2, .8, .2, 1);
|
||||
--motion-fast: 130ms;
|
||||
--motion: 210ms;
|
||||
--motion-slow: 360ms;
|
||||
color: var(--text);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
html { background: var(--canvas); }
|
||||
body {
|
||||
background:
|
||||
linear-gradient(rgba(118, 171, 198, .025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(118, 171, 198, .025) 1px, transparent 1px),
|
||||
radial-gradient(ellipse 70% 40% at 50% -10%, rgba(61, 132, 150, .16), transparent 72%),
|
||||
var(--canvas);
|
||||
background-size: 44px 44px, 44px 44px, auto, auto;
|
||||
}
|
||||
button, a, input, select, summary { transition-duration: var(--motion-fast); transition-timing-function: var(--ease); }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { border-color: var(--accent); outline-color: rgba(117, 209, 204, .27); }
|
||||
button { border-color: var(--border-strong); border-radius: var(--radius-sm); background: #174052; }
|
||||
button:hover:not(:disabled) { border-color: var(--accent); background: #1a4a5d; }
|
||||
.app-header { width: min(100% - 56px, 1500px); min-height: 72px; border-bottom-color: var(--border); background: rgba(7, 17, 28, .82); }
|
||||
.brand-home { color: var(--text); }
|
||||
.brand-home:hover { color: var(--accent-bright); }
|
||||
.brand-lockup-copy { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.brand-lockup-copy > span:last-child { display: grid; gap: 1px; }
|
||||
.brand-lockup-copy strong { display: block; font-size: 16px; letter-spacing: -.04em; }
|
||||
.brand-lockup-copy small { color: var(--text-subtle); font-size: 9px; font-weight: 650; letter-spacing: .11em; text-transform: uppercase; }
|
||||
.brand-svg { display: block; flex: 0 0 auto; color: var(--accent); filter: drop-shadow(0 4px 10px rgba(117, 209, 204, .17)); }
|
||||
.global-nav { gap: 12px; }
|
||||
.global-nav a { border-bottom-width: 1px; padding: 0 8px; color: var(--text-muted); }
|
||||
.global-nav a.active { border-bottom-color: var(--accent); color: var(--text); background: transparent; }
|
||||
.global-nav a:hover { color: var(--accent-bright); }
|
||||
.page-content { width: min(100% - 56px, 1500px); padding-top: 34px; }
|
||||
.eyebrow { color: var(--accent); font-weight: 720; }
|
||||
.muted { color: var(--text-muted); }
|
||||
.section-card { border-color: var(--border); border-radius: var(--radius); background: rgba(13, 27, 43, .86); box-shadow: var(--shadow); }
|
||||
.count-badge { border-color: var(--border-strong); color: var(--accent-bright); background: rgba(117, 209, 204, .08); }
|
||||
.brand-mark { display: none; }
|
||||
.header-actions { gap: 8px; }
|
||||
.shortcut-button, .icon-button { border-color: var(--border); background: rgba(14, 29, 45, .72); }
|
||||
.shortcut-button:hover:not(:disabled), .icon-button:hover:not(:disabled) { background: var(--surface-hover); }
|
||||
|
||||
/* Dashboard: composed sections rather than metric-card soup. */
|
||||
.dashboard-page { display: grid; gap: 18px; }
|
||||
.dashboard-hero { position: relative; display: flex; align-items: end; justify-content: space-between; min-height: 190px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 30px 34px; background: linear-gradient(120deg, rgba(18, 42, 58, .94), rgba(12, 24, 39, .91)); box-shadow: var(--shadow); }
|
||||
.dashboard-hero::after { position: absolute; inset: auto -10% -70% auto; width: 440px; height: 440px; border: 1px solid rgba(117, 209, 204, .16); border-radius: 50%; box-shadow: 0 0 0 50px rgba(117, 209, 204, .025), 0 0 0 100px rgba(117, 209, 204, .018); content: ''; pointer-events: none; }
|
||||
.dashboard-hero > div:first-child { position: relative; z-index: 1; }
|
||||
.dashboard-hero h1 { max-width: 700px; margin: 8px 0 10px; font-size: clamp(32px, 4vw, 52px); }
|
||||
.dashboard-hero h1 span { color: var(--accent-bright); }
|
||||
.dashboard-hero p { margin: 0; color: var(--text-muted); font-size: 14px; }
|
||||
.portfolio-signal { position: relative; z-index: 1; display: grid; justify-items: end; min-width: 230px; color: var(--accent); }
|
||||
.portfolio-signal svg { width: 210px; height: 100px; filter: drop-shadow(0 0 18px rgba(117, 209, 204, .18)); }
|
||||
.portfolio-signal strong { margin-top: -53px; color: var(--text); font-size: 26px; letter-spacing: -.05em; }
|
||||
.portfolio-signal span { color: var(--text-muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.dashboard-health-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--border); }
|
||||
.health-strip-segment { display: grid; justify-items: start; min-height: 106px; border: 0; border-radius: 0; padding: 17px 20px; color: var(--text); background: rgba(12, 26, 41, .95); text-align: left; }
|
||||
.health-strip-segment:hover:not(:disabled) { transform: translateY(-1px); border: 0; background: var(--surface-hover); }
|
||||
.health-strip-segment span { color: var(--text-muted); font-size: 11px; }
|
||||
.health-strip-segment strong { margin: 5px 0 3px; font-size: 28px; letter-spacing: -.06em; }
|
||||
.health-strip-segment small { color: var(--text-subtle); font-size: 10px; }
|
||||
.health-strip-segment.ready strong { color: var(--ready); }
|
||||
.health-strip-segment.warnings strong { color: var(--warning); }
|
||||
.health-strip-segment.blocked strong { color: var(--blocked); }
|
||||
.health-strip-segment.all strong { color: var(--accent-bright); }
|
||||
.dashboard-layout { display: grid; grid-template-columns: minmax(330px, .92fr) minmax(0, 1.45fr); gap: 18px; }
|
||||
.dashboard-layout.lower { grid-template-columns: minmax(0, 1.45fr) minmax(330px, .92fr); }
|
||||
.dashboard-section { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; background: rgba(11, 24, 39, .76); }
|
||||
.dashboard-section h2 { margin: 5px 0 0; font-size: 20px; }
|
||||
.attention-list, .activity-rail { display: grid; gap: 2px; }
|
||||
.attention-row, .activity-rail-item { display: grid; grid-template-columns: 45px minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; border: 0; border-radius: 8px; padding: 9px 7px; color: var(--text); background: transparent; text-align: left; }
|
||||
.attention-row:hover:not(:disabled), .activity-rail-item:hover:not(:disabled) { border: 0; background: var(--surface-hover); }
|
||||
.attention-row strong, .activity-rail-item strong { display: block; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.attention-row small, .activity-rail-item small { display: block; margin-top: 3px; overflow: hidden; color: var(--text-subtle); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.attention-row b, .activity-rail-item b { color: var(--text); font-size: 18px; letter-spacing: -.05em; }
|
||||
.repository-visual { position: relative; display: grid; width: 100%; aspect-ratio: 1.6; place-items: center; overflow: hidden; border: 1px solid rgba(155, 184, 214, .11); border-radius: 12px; color: #7999bc; background: radial-gradient(circle at 50% 50%, rgba(94, 144, 168, .14), transparent 65%), #0a1726; }
|
||||
.repository-visual.compact { width: 42px; aspect-ratio: 1; border-radius: 10px; }
|
||||
.repository-visual.ready { color: var(--ready); }
|
||||
.repository-visual.warnings { color: var(--warning); }
|
||||
.repository-visual.blocked { color: var(--blocked); }
|
||||
.repository-visual svg { width: 100%; height: 100%; overflow: visible; }
|
||||
.repository-visual:not(.compact):hover .visual-orbit { stroke-dashoffset: -18px; }
|
||||
.visual-orbit { transition: stroke-dashoffset var(--motion-slow) var(--ease); }
|
||||
.visual-node { transform-origin: center; transition: r var(--motion) var(--ease), opacity var(--motion) var(--ease); }
|
||||
.repository-visual:hover .visual-node.hot { opacity: 1; }
|
||||
.visual-scan-pulse { position: absolute; inset: 10%; border: 1px solid currentColor; border-radius: inherit; animation: repo-pulse 1.45s var(--ease) infinite; }
|
||||
@keyframes repo-pulse { 0% { opacity: .8; transform: scale(.72); } 100% { opacity: 0; transform: scale(1.06); } }
|
||||
.matrix-section { min-height: 294px; }
|
||||
.portfolio-matrix { display: grid; grid-template-columns: repeat(auto-fit, minmax(28px, 1fr)); gap: 7px; min-height: 180px; align-content: center; padding: 13px 3px 9px; }
|
||||
.matrix-cell { display: grid; aspect-ratio: 1; min-width: 0; place-items: center; border: 1px solid transparent; border-radius: 7px; padding: 0; color: rgba(255, 255, 255, .85); background: #395b7a; font-size: 8px; font-weight: 700; transform: scale(var(--cell-scale)); }
|
||||
.matrix-cell span { overflow: hidden; max-width: 100%; text-overflow: clip; }
|
||||
.matrix-cell.ready { background: rgba(100, 211, 155, .78); }
|
||||
.matrix-cell.warnings { background: rgba(233, 187, 113, .78); }
|
||||
.matrix-cell.blocked { background: rgba(237, 113, 136, .8); }
|
||||
.matrix-cell:hover:not(:disabled), .matrix-cell:focus-visible { position: relative; z-index: 2; border-color: var(--text); background: var(--accent); color: #0a1726; transform: scale(1.17); }
|
||||
.matrix-legend { display: flex; flex-wrap: wrap; gap: 12px; color: var(--text-subtle); font-size: 10px; }
|
||||
.matrix-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.matrix-legend i { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.matrix-legend .ready { background: var(--ready); }.matrix-legend .warnings { background: var(--warning); }.matrix-legend .blocked { background: var(--blocked); }
|
||||
.activity-rail-item { grid-template-columns: 11px minmax(0, 1fr) auto; padding-left: 0; }
|
||||
.status-orb { width: 7px; height: 7px; border-radius: 50%; background: var(--text-subtle); box-shadow: 0 0 0 4px rgba(134, 153, 174, .09); }
|
||||
.status-orb.ready { background: var(--ready); box-shadow: 0 0 0 4px rgba(100, 211, 155, .1); }.status-orb.warnings { background: var(--warning); }.status-orb.blocked { background: var(--blocked); }
|
||||
.pinned-grid { display: grid; gap: 3px; }
|
||||
.pinned-tile { display: grid; grid-template-columns: 45px minmax(0, 1fr); align-items: center; gap: 10px; border: 0; border-radius: 8px; padding: 9px 7px; background: transparent; text-align: left; }.pinned-tile:hover:not(:disabled) { border: 0; background: var(--surface-hover); }
|
||||
.pinned-tile strong, .pinned-tile small { display: block; }.pinned-tile strong { font-size: 12px; }.pinned-tile small { margin-top: 3px; color: var(--text-subtle); font-size: 10px; }
|
||||
.pin-empty { display: grid; gap: 8px; padding: 8px 0; color: var(--text-subtle); font-size: 11px; }
|
||||
|
||||
/* Explorer uses two intentional, persistent modes. */
|
||||
.catalog-toolbar { border-color: var(--border); }
|
||||
.catalog-toolbar.section-card { padding: 15px; }
|
||||
.toolbar-footer { min-height: 29px; }
|
||||
.view-toggle { display: inline-flex; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: rgba(5, 13, 23, .46); }
|
||||
.view-toggle button { border: 0; border-radius: 0; padding: 6px 10px; color: var(--text-muted); background: transparent; font-size: 10px; }.view-toggle button:hover:not(:disabled) { border: 0; background: var(--surface-hover); }.view-toggle button.active { color: #06151d; background: var(--accent); }
|
||||
.repository-table { min-width: 820px; }.repository-table th { position: sticky; z-index: 2; top: 72px; border-bottom-color: var(--border); background: #0f1e30; }.repository-table td { border-bottom-color: rgba(132, 160, 193, .1); }.repository-table tbody tr { cursor: pointer; }.repository-table tbody tr:hover, .repository-table tbody tr:focus-within { background: rgba(117, 209, 204, .065); }.repository-table tbody tr:focus { outline: 2px solid rgba(117, 209, 204, .72); outline-offset: -2px; }.repo-link { color: var(--text); }.repo-link:hover:not(:disabled) { color: var(--accent-bright); }.catalog-table-card { border-color: var(--border); }.pagination-bar { border-top-color: var(--border); }
|
||||
.repository-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 13px; }.repository-tile { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 10px 32px rgba(0, 0, 0, .09); }.repository-tile-main { display: grid; width: 100%; gap: 11px; border: 0; border-radius: 0; padding: 10px; color: var(--text); background: transparent; text-align: left; }.repository-tile-main:hover:not(:disabled) { border: 0; background: linear-gradient(140deg, rgba(117, 209, 204, .1), transparent 50%); }.repository-tile-main:hover .repository-visual { transform: translateY(-2px); border-color: var(--border-strong); }.repository-tile .repository-visual { transition: transform var(--motion) var(--ease), border-color var(--motion) var(--ease); }.tile-heading, .tile-meta { display: flex; align-items: center; justify-content: space-between; gap: 9px; }.tile-heading > span { min-width: 0; }.tile-heading strong, .tile-heading small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.tile-heading strong { font-size: 13px; }.tile-heading small, .tile-meta > span:last-child { margin-top: 3px; color: var(--text-subtle); font-size: 10px; }.tile-heading b { color: var(--accent-bright); font-size: 20px; letter-spacing: -.06em; }.tile-pin { position: absolute; top: 14px; right: 14px; z-index: 2; width: 28px; height: 28px; border: 0; border-radius: 7px; padding: 0; color: var(--text-muted); background: rgba(5, 14, 24, .52); }.tile-pin:hover:not(:disabled) { border: 0; color: #ffe29a; background: rgba(5, 14, 24, .82); }.tile-pin.pinned { color: #f4cc75; }
|
||||
.catalog-loading, .workspace-loading { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }.catalog-loading span, .workspace-loading span { display: block; min-height: 190px; border-radius: var(--radius); background: linear-gradient(100deg, rgba(126, 155, 184, .05) 15%, rgba(126, 155, 184, .14) 50%, rgba(126, 155, 184, .05) 85%); background-size: 200% 100%; animation: skeleton 1.3s linear infinite; }@keyframes skeleton { to { background-position: -200% 0; } }
|
||||
|
||||
/* Workspace puts identity, health and true architecture-first visual evidence together. */
|
||||
.workspace-hero { display: grid; grid-template-columns: minmax(0, 1fr) 190px auto; align-items: center; gap: 25px; min-height: 176px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 21px 24px; background: linear-gradient(120deg, rgba(14, 33, 48, .94), rgba(12, 24, 39, .95)); box-shadow: var(--shadow); }.workspace-identity { min-width: 0; }.workspace-identity > div:first-child { display: flex; align-items: flex-start; justify-content: space-between; gap: 15px; }.workspace-identity h1 { margin: 5px 0 6px; overflow: hidden; font-size: clamp(24px, 3vw, 36px); text-overflow: ellipsis; white-space: nowrap; }.workspace-identity p { max-width: 650px; margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; }.workspace-facts { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 13px; }.workspace-facts > span { border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; background: rgba(6, 17, 28, .31); font-size: 10px; }.workspace-score { display: grid; align-content: center; justify-items: end; min-width: 85px; }.workspace-score strong { color: var(--accent-bright); font-size: 42px; letter-spacing: -.09em; line-height: .85; }.workspace-score span { margin-top: 5px; color: var(--text-subtle); font-size: 9px; letter-spacing: .09em; text-transform: uppercase; }.workspace-score small { max-width: 100px; margin-top: 5px; overflow: hidden; color: var(--text-muted); font-size: 9px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }.workspace-visual { width: 180px; align-self: stretch; min-height: 132px; }.workspace-hero .detail-actions { flex-wrap: wrap; max-width: 144px; }.workspace-hero .detail-actions > button:first-child { flex: 1 0 100%; }.detail-tabs { margin-top: 16px; border-bottom-color: var(--border); }.detail-tabs a.active { border-bottom-color: var(--accent); color: var(--accent-bright); }
|
||||
.dependency-hero { overflow: hidden; background: linear-gradient(120deg, rgba(17, 43, 54, .76), rgba(12, 25, 39, .82)); }.dependency-metrics { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; margin: 18px -18px -18px; border-top: 1px solid var(--border); background: var(--border); }.dependency-metrics div { display: grid; gap: 4px; padding: 13px 17px; background: var(--surface); }.dependency-metrics strong { color: var(--accent-bright); font-size: 21px; }.dependency-metrics span { color: var(--text-subtle); font-size: 9px; letter-spacing: .06em; text-transform: uppercase; }.manifest-list { display: flex; flex-wrap: wrap; gap: 7px; }.manifest-list code { border: 1px solid var(--border); border-radius: 7px; padding: 7px 8px; background: rgba(4, 12, 20, .35); }.dependency-lock-heading { margin-top: 24px; }.scan-runs-workspace { min-height: 270px; }.run-readiness { color: var(--text-subtle); font-size: 10px; }.run-readiness.ready { color: var(--ready); }.run-readiness.warnings { color: var(--warning); }.run-readiness.blocked { color: var(--blocked); }
|
||||
|
||||
/* Operational views. */
|
||||
.summary-strip > div { border-color: var(--border); border-radius: var(--radius-sm); background: rgba(13, 27, 43, .74); }.summary-strip strong { color: var(--accent-bright); }.activity-timeline { padding: 20px 24px; }.timeline-event { position: relative; display: grid; grid-template-columns: 14px 44px minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; border: 0; border-radius: 8px; padding: 10px 7px; color: var(--text); background: transparent; text-align: left; }.timeline-event:hover:not(:disabled) { border: 0; background: var(--surface-hover); }.timeline-rail { width: 8px; height: 8px; border: 2px solid var(--canvas); border-radius: 50%; background: var(--text-subtle); box-shadow: 0 0 0 1px var(--border-strong); }.timeline-rail.ready { background: var(--ready); }.timeline-rail.warnings { background: var(--warning); }.timeline-rail.blocked { background: var(--blocked); }.timeline-event::before { position: absolute; top: 0; bottom: 0; left: 10px; width: 1px; background: var(--border); content: ''; }.timeline-event:first-of-type::before { top: 50%; }.timeline-event:last-of-type::before { bottom: 50%; }.timeline-event > * { position: relative; z-index: 1; }.timeline-event strong, .timeline-event small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.timeline-event strong { font-size: 12px; }.timeline-event small { margin-top: 3px; color: var(--text-subtle); font-size: 10px; }.timeline-event b { color: var(--accent-bright); font-size: 20px; }.queue-row { grid-template-columns: 45px minmax(0, 1fr) auto; border-color: var(--border); background: transparent; cursor: pointer; }.queue-row:hover:not(:disabled) { border-color: var(--accent); background: var(--surface-hover); }.queue-empty { display: grid; grid-template-columns: 72px minmax(0, 550px); align-items: start; gap: 18px; min-height: 250px; padding: 32px 5px; }.queue-empty h2 { margin: 5px 0 7px; }.queue-empty .muted { margin-bottom: 17px; }
|
||||
|
||||
/* Settings sections signal separation without form-card nesting. */
|
||||
.settings-sections { display: grid; gap: 13px; }.settings-section { display: grid; grid-template-columns: minmax(210px, .72fr) minmax(260px, 1fr) auto; gap: 24px; align-items: start; border: 1px solid var(--border); border-radius: var(--radius); padding: 21px; background: rgba(13, 27, 43, .78); }.settings-section h2 { margin: 5px 0 7px; }.settings-section p { margin: 0; color: var(--text-muted); font-size: 11px; line-height: 1.5; }.settings-section dl { display: grid; gap: 8px; margin: 0; }.settings-section dl > div { display: flex; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--border); padding: 7px 0; }.settings-section dt { color: var(--text-subtle); font-size: 10px; }.settings-section dd { margin: 0; color: var(--text); font-size: 11px; text-align: right; }.settings-section .local-form { margin: 0; grid-column: 2 / -1; }.settings-section .local-result { grid-column: 2 / -1; }.settings-section.appearance { grid-template-columns: minmax(210px, .72fr) minmax(260px, 1fr); }.settings-section.appearance dl { grid-column: 2; }
|
||||
.command-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 15px 11px; }.command-row button { border-color: var(--border); padding: 6px 8px; color: var(--text-muted); background: rgba(7, 18, 29, .52); font-size: 10px; }.command-row button:hover:not(:disabled) { color: var(--accent-bright); background: var(--surface-hover); }.switcher-item { grid-template-columns: 38px minmax(0, 1fr) auto; }.switcher-item .repository-visual { width: 32px; }.switcher-modal { border-color: var(--border-strong); border-radius: var(--radius); background: #0d1b2b; }.switcher-item:hover:not(:disabled), .switcher-item.active { border-color: rgba(117, 209, 204, .36); background: rgba(117, 209, 204, .1); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@media (max-width: 1180px) { .dashboard-layout, .dashboard-layout.lower { grid-template-columns: 1fr; }.repository-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }.workspace-hero { grid-template-columns: minmax(0, 1fr) 160px; }.workspace-hero .detail-actions { grid-column: 1 / -1; max-width: none; justify-content: flex-start; }.workspace-hero .detail-actions > button:first-child { flex: 0 0 auto; }.settings-section { grid-template-columns: minmax(210px, .7fr) minmax(240px, 1fr); }.settings-section > button { grid-column: 2; justify-self: start; } }
|
||||
@media (max-width: 760px) { .app-header, .page-content, .global-banner { width: min(100% - 28px, 1500px); }.app-header { grid-template-columns: auto 1fr; }.brand-lockup-copy small { display: none; }.global-nav { grid-column: 1 / -1; grid-row: 2; margin: 0 -2px; scrollbar-width: none; -ms-overflow-style: none; }.global-nav::-webkit-scrollbar { display: none; }.header-actions { justify-content: end; }.dashboard-hero { min-height: 180px; padding: 24px; }.dashboard-hero::after, .portfolio-signal { display: none; }.dashboard-health-strip { grid-template-columns: repeat(2, 1fr); }.dashboard-layout { gap: 13px; }.repository-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.workspace-hero { grid-template-columns: 1fr; gap: 14px; }.workspace-identity > div:first-child { display: block; }.workspace-score { justify-items: start; }.workspace-score small { text-align: left; }.workspace-visual { width: 100%; max-width: 250px; min-height: 150px; }.dependency-metrics { grid-template-columns: repeat(2, 1fr); }.settings-section, .settings-section.appearance { grid-template-columns: 1fr; gap: 14px; }.settings-section dl, .settings-section > button, .settings-section .local-form, .settings-section .local-result, .settings-section.appearance dl { grid-column: auto; }.timeline-event { grid-template-columns: 14px 40px minmax(0, 1fr) auto; }.queue-empty { grid-template-columns: 1fr; gap: 10px; }.queue-empty .brand-svg { display: none; } }
|
||||
@media (max-width: 470px) { .page-content { padding-top: 23px; }.dashboard-hero { padding: 20px; }.dashboard-hero h1 { font-size: 31px; }.health-strip-segment { min-height: 87px; padding: 14px; }.health-strip-segment strong { font-size: 24px; }.repository-grid { grid-template-columns: 1fr; }.portfolio-matrix { grid-template-columns: repeat(8, 1fr); }.matrix-cell { font-size: 7px; }.toolbar-footer { align-items: flex-start; flex-wrap: wrap; }.view-toggle { order: -1; }.workspace-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }.workspace-facts > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.timeline-event { grid-template-columns: 12px 35px minmax(0, 1fr); }.timeline-event b { display: none; }.dependency-metrics { grid-template-columns: 1fr 1fr; }.settings-section { padding: 17px; } }
|
||||
|
||||
/* v1.3 portfolio command deck ------------------------------------------------ */
|
||||
.cockpit-page { gap: 15px; }
|
||||
.dashboard-command { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 235px 285px; align-items: center; min-height: 198px; overflow: hidden; border: 1px solid rgba(130, 204, 202, .2); border-radius: 22px; padding: 28px 32px; background: linear-gradient(118deg, rgba(15, 42, 57, .98), rgba(10, 25, 42, .98) 52%, rgba(19, 35, 55, .94)); box-shadow: 0 28px 80px rgba(0, 0, 0, .24); }
|
||||
.dashboard-command::before { position: absolute; inset: 0; background: linear-gradient(90deg, transparent 0 47%, rgba(117, 209, 204, .09) 47.1%, transparent 47.2%), repeating-linear-gradient(0deg, transparent 0 33px, rgba(133, 176, 201, .04) 34px); content: ''; pointer-events: none; }
|
||||
.dashboard-command > * { position: relative; z-index: 1; }.command-copy h1 { margin: 7px 0 7px; color: var(--text); font-size: clamp(34px, 4vw, 55px); letter-spacing: -.075em; line-height: .98; }.command-copy h1 span { color: var(--accent-bright); }.command-copy h1 small { margin-left: 8px; color: var(--text-subtle); font-size: .38em; letter-spacing: -.03em; }.command-copy p { margin: 0; color: var(--text-muted); font-size: 12px; }.command-pulse { display: grid; justify-items: center; color: var(--accent); }.command-pulse svg { width: 205px; height: 104px; overflow: visible; }.pulse-field { fill: none; stroke: currentColor; stroke-opacity: .15; stroke-width: 11; }.pulse-line { fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; filter: drop-shadow(0 0 10px rgba(117, 209, 204, .35)); }.command-pulse circle { fill: var(--accent-bright); filter: drop-shadow(0 0 8px var(--accent)); }.command-pulse strong { margin-top: -51px; color: var(--text); font-size: 27px; letter-spacing: -.06em; }.command-pulse span { margin-top: 4px; color: var(--text-subtle); font-size: 9px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }.command-context { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin: 0; overflow: hidden; border: 1px solid rgba(135, 174, 200, .17); border-radius: 12px; background: rgba(135, 174, 200, .12); }.command-context div { display: grid; gap: 5px; padding: 15px 12px; background: rgba(7, 19, 33, .36); }.command-context dt { color: var(--text-subtle); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }.command-context dd { margin: 0; color: var(--text); font-size: 23px; font-weight: 760; letter-spacing: -.07em; }.command-context div:first-child dd { color: var(--blocked); }.command-context div:nth-child(2) dd { color: var(--warning); }.command-context div:last-child dd { color: var(--ready); }
|
||||
.dashboard-readiness-rail { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--border); }.readiness-rail-segment { display: grid; justify-items: start; min-height: 92px; border: 0; border-radius: 0; padding: 14px 19px; color: var(--text); background: rgba(11, 25, 40, .91); text-align: left; }.readiness-rail-segment:hover:not(:disabled) { border: 0; background: linear-gradient(135deg, rgba(117, 209, 204, .16), rgba(19, 42, 61, .9)); }.readiness-rail-segment > span { color: var(--text-subtle); font-size: 9px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }.readiness-rail-segment strong { margin: 3px 0; font-size: 27px; letter-spacing: -.07em; }.readiness-rail-segment small { color: var(--text-muted); font-size: 10px; }.readiness-rail-segment small b { margin-left: 5px; color: var(--accent); font-size: 14px; }.readiness-rail-segment.blocked strong { color: var(--blocked); }.readiness-rail-segment.warnings strong { color: var(--warning); }.readiness-rail-segment.ready strong { color: var(--ready); }.readiness-rail-segment.all strong { color: var(--accent-bright); }
|
||||
.cockpit-focus-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .75fr); gap: 15px; }.dashboard-section { border-color: rgba(132, 160, 193, .18); background: linear-gradient(135deg, rgba(13, 29, 46, .9), rgba(10, 22, 37, .82)); }.attention-section { padding: 19px; }.attention-list { gap: 3px; }.attention-row { grid-template-columns: 25px 49px minmax(0, 1fr) auto; gap: 10px; min-height: 58px; padding: 7px 8px; border: 1px solid transparent; border-radius: 10px; }.attention-row:hover:not(:disabled), .attention-row:focus-visible { border-color: rgba(117, 209, 204, .36); background: rgba(117, 209, 204, .065); }.attention-rank { color: var(--text-subtle); font-size: 9px; font-weight: 750; letter-spacing: .08em; }.attention-row .repository-visual.compact { width: 46px; height: 46px; }.attention-copy { min-width: 0; }.attention-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attention-copy small { overflow: hidden; color: var(--text-muted); font-size: 10px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }.attention-copy em { display: block; margin-top: 3px; color: var(--text-subtle); font-size: 9px; font-style: normal; }.attention-row b { color: var(--blocked); font-size: 22px; }.next-move { position: relative; display: grid; align-content: start; overflow: hidden; padding: 21px; background: linear-gradient(145deg, rgba(18, 47, 59, .88), rgba(9, 22, 38, .96)); }.next-move::after { position: absolute; right: -45px; bottom: -75px; width: 190px; height: 190px; border: 1px solid rgba(117, 209, 204, .15); border-radius: 50%; box-shadow: 0 0 0 28px rgba(117, 209, 204, .025), 0 0 0 58px rgba(117, 209, 204, .018); content: ''; pointer-events: none; }.next-move > * { position: relative; z-index: 1; }.next-move h2 { margin: 6px 0 14px; overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; }.next-move-action { display: grid; grid-template-columns: 122px minmax(0, 1fr); align-items: center; gap: 13px; width: 100%; border: 1px solid rgba(117, 209, 204, .18); padding: 10px; color: var(--text); background: rgba(5, 17, 29, .38); text-align: left; }.next-move-action:hover:not(:disabled) { border-color: var(--accent); background: rgba(10, 35, 49, .76); }.next-move-action .repository-visual { min-height: 94px; }.next-move-action span { min-width: 0; }.next-move-action strong, .next-move-action small, .next-move-action b { display: block; }.next-move-action strong { color: var(--accent-bright); font-size: 24px; letter-spacing: -.07em; }.next-move-action small { margin-top: 5px; color: var(--text-muted); font-size: 10px; line-height: 1.45; }.next-move-action b { margin-top: 11px; color: var(--accent); font-size: 10px; }
|
||||
.portfolio-atlas { padding: 20px; }.atlas-heading { align-items: end; }.atlas-field { position: relative; overflow: hidden; border: 1px solid rgba(132, 160, 193, .14); border-radius: 13px; background: radial-gradient(ellipse at 70% 0, rgba(77, 166, 175, .1), transparent 45%), rgba(5, 16, 27, .52); }.atlas-contours { position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; pointer-events: none; }.atlas-contours path { fill: none; stroke: rgba(117, 209, 204, .12); stroke-width: 1; }.atlas-contours path:nth-child(2) { stroke: rgba(233, 187, 113, .1); stroke-dasharray: 4 6; }.atlas-contours path:nth-child(3) { stroke: rgba(237, 113, 136, .12); }.atlas-bands { position: relative; z-index: 1; display: grid; gap: 1px; background: rgba(135, 174, 200, .1); }.atlas-band { display: grid; grid-template-columns: 180px minmax(0, 1fr); min-height: 96px; background: rgba(8, 21, 35, .84); }.atlas-band > header { display: grid; align-content: center; gap: 3px; padding: 14px 17px; border-right: 1px solid rgba(135, 174, 200, .1); background: linear-gradient(90deg, rgba(16, 34, 51, .74), transparent); }.atlas-band > header span { color: var(--text-subtle); font-size: 9px; font-weight: 720; letter-spacing: .06em; text-transform: uppercase; }.atlas-band > header strong { color: var(--text); font-size: 28px; letter-spacing: -.07em; }.atlas-band > header small { color: var(--text-muted); font-size: 9px; line-height: 1.25; }.atlas-band.blocked > header strong { color: var(--blocked); }.atlas-band.warnings > header strong { color: var(--warning); }.atlas-band.ready > header strong { color: var(--ready); }.atlas-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(57px, 1fr)); align-content: center; gap: 6px; margin: 0; padding: 10px; list-style: none; }.atlas-repository { position: relative; display: grid; align-content: space-between; width: 100%; min-width: 0; height: 58px; overflow: hidden; border: 1px solid rgba(149, 184, 207, .16); border-radius: 8px; padding: 7px; color: var(--text); background: linear-gradient(145deg, rgba(29, 55, 75, .8), rgba(13, 29, 45, .82)); text-align: left; }.atlas-repository::before { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255,255,255,.07), transparent 55%); content: ''; pointer-events: none; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { z-index: 2; border-color: var(--accent); background: linear-gradient(145deg, rgba(46, 96, 110, .94), rgba(13, 40, 53, .98)); box-shadow: 0 0 0 3px rgba(117, 209, 204, .12), 0 10px 20px rgba(0, 0, 0, .24); transform: translateY(-2px); }.atlas-repository-name, .atlas-repository-health { position: relative; z-index: 1; overflow: hidden; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }.atlas-repository-name { color: rgba(244, 250, 255, .84); font-size: 9px; font-weight: 770; letter-spacing: .04em; }.atlas-repository-health { align-self: end; color: var(--text-muted); font-size: 11px; font-weight: 700; }.atlas-repository > i { position: absolute; z-index: 1; bottom: 5px; left: 7px; width: var(--signal-strength); max-width: calc(100% - 14px); height: 2px; border-radius: 2px; background: var(--accent); box-shadow: 0 0 8px rgba(117, 209, 204, .45); }.atlas-band.blocked .atlas-repository > i { background: var(--blocked); box-shadow: 0 0 8px rgba(237, 113, 136, .42); }.atlas-band.warnings .atlas-repository > i { background: var(--warning); box-shadow: 0 0 8px rgba(233, 187, 113, .38); }.atlas-band.ready .atlas-repository > i { background: var(--ready); box-shadow: 0 0 8px rgba(100, 211, 155, .38); }.atlas-repository.stale { border-style: dashed; }.atlas-repository.scanning::after { position: absolute; inset: 3px; border: 1px solid currentColor; border-radius: 5px; content: ''; animation: atlas-scan 1.35s var(--ease) infinite; }.atlas-legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 11px 1px 0; color: var(--text-subtle); font-size: 9px; }.atlas-legend span { display: inline-flex; align-items: center; gap: 5px; }.atlas-legend i { width: 14px; height: 2px; border-radius: 2px; background: var(--accent); }.atlas-legend .blocked { background: var(--blocked); }.atlas-legend .stale { height: 6px; border: 1px dashed var(--text-subtle); background: transparent; }.atlas-legend .scanning { width: 7px; height: 7px; border: 1px solid var(--accent); border-radius: 50%; background: transparent; }.portfolio-atlas[data-density='dense'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 4px; }.portfolio-atlas[data-density='dense'] .atlas-repository { height: 45px; padding: 5px; }.portfolio-atlas[data-density='dense'] .atlas-repository-name { font-size: 8px; }.portfolio-atlas[data-density='compact'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(28px, 1fr)); gap: 3px; }.portfolio-atlas[data-density='compact'] .atlas-repository { height: 31px; padding: 4px; border-radius: 5px; }.portfolio-atlas[data-density='compact'] .atlas-reposiLine truncated
|
||||
.dashboard-bottom-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(330px, .85fr); gap: 15px; }.dashboard-bottom-grid .dashboard-section { padding: 18px; }.activity-rail-item { border-radius: 8px; }.pinned-tile { grid-template-columns: 46px minmax(0, 1fr) auto; }.pinned-tile b { color: var(--accent); }.repository-visual { border-color: rgba(140, 187, 207, .15); color: #8aaac7; background: radial-gradient(ellipse at 70% 20%, rgba(93, 159, 181, .18), transparent 58%), #091827; }.repository-visual.state-ready { color: var(--ready); }.repository-visual.state-warnings { color: var(--warning); }.repository-visual.state-blocked { color: var(--blocked); }.repository-visual svg { overflow: hidden; }.visual-wash { opacity: .75; }.visual-link { transition: stroke-opacity var(--motion) var(--ease); }.visual-contour { transform-origin: center; }.repository-visual:not(.compact):hover .contour-one { animation: visual-drift 2.8s var(--ease) infinite alternate; }.repository-visual:not(.compact):hover .visual-link { stroke-opacity: .95; }.visual-node { transform-origin: center; transition: opacity var(--motion) var(--ease); }.visual-node.hot { animation: visual-node-breathe 2.7s var(--ease) infinite; }.visual-caption { position: absolute; top: 9px; left: 10px; display: flex; gap: 5px; align-items: center; }.visual-caption i { border: 1px solid currentColor; border-radius: 99px; padding: 2px 4px; color: currentColor; font-size: 7px; font-style: normal; font-weight: 760; letter-spacing: .09em; }.visual-caption b { color: rgba(238, 247, 255, .76); font-size: 8px; font-weight: 650; }.repository-visual.compact .visual-health-line, .repository-visual.compact .visual-contour { opacity: .65; }.workspace-visual { min-height: 132px; }.workspace-visual .visual-caption { top: 11px; left: 12px; }.workspace-visual .visual-caption b { font-size: 9px; }.repository-tile .repository-visual { min-height: 124px; }.repository-tile-main:hover .repository-visual { box-shadow: 0 14px 24px rgba(0, 0, 0, .18); }
|
||||
@keyframes atlas-scan { 0% { opacity: .8; transform: scale(.82); } 100% { opacity: 0; transform: scale(1.06); } } @keyframes visual-drift { to { transform: translateX(4px); } } @keyframes visual-node-breathe { 50% { opacity: .7; } }
|
||||
@media (max-width: 1180px) { .dashboard-command { grid-template-columns: minmax(0, 1fr) 205px; }.command-context { grid-column: 1 / -1; max-width: 420px; }.cockpit-focus-grid, .dashboard-bottom-grid { grid-template-columns: 1fr; }.next-move { min-height: 180px; }.atlas-band { grid-template-columns: 158px minmax(0, 1fr); } }
|
||||
@media (max-width: 760px) { .dashboard-command { grid-template-columns: 1fr; min-height: 0; padding: 23px 20px; }.command-pulse { position: absolute; right: -16px; bottom: -23px; opacity: .32; }.command-context { max-width: none; margin-top: 18px; }.command-copy { padding-right: 40px; }.command-copy h1 { font-size: 39px; }.dashboard-readiness-rail { grid-template-columns: repeat(2, 1fr); }.readiness-rail-segment { min-height: 78px; padding: 12px; }.readiness-rail-segment strong { font-size: 23px; }.cockpit-focus-grid { gap: 12px; }.attention-section, .next-move, .portfolio-atlas, .dashboard-bottom-grid .dashboard-section { padding: 15px; }.next-move-action { grid-template-columns: 105px minmax(0, 1fr); }.atlas-band { grid-template-columns: 1fr; }.atlas-band > header { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 9px; border-right: 0; border-bottom: 1px solid rgba(135, 174, 200, .1); padding: 11px 13px; }.atlas-band > header strong { grid-row: 1 / 3; grid-column: 2; align-self: center; }.atlas-band > header small { grid-column: 1; }.atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(49px, 1fr)); padding: 8px; }.atlas-legend { gap: 8px; }.dashboard-bottom-grid { gap: 12px; }.workspace-visual { max-width: none; } }
|
||||
@media (max-width: 470px) { .command-context div { padding: 11px 9px; }.command-context dd { font-size: 20px; }.attention-row { grid-template-columns: 20px 42px minmax(0, 1fr) auto; gap: 7px; padding: 7px 3px; }.attention-row .repository-visual.compact { width: 40px; height: 40px; }.attention-copy small { white-space: normal; }.attention-copy em { display: none; }.next-move-action { grid-template-columns: 88px minmax(0, 1fr); }.next-move-action .repository-visual { min-height: 76px; }.atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 4px; }.atlas-repository { height: 48px; padding: 5px; }.atlas-repository-name { font-size: 8px; }.atlas-legend { display: grid; }.portfolio-atlas[data-density='compact'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(23px, 1fr)); } }
|
||||
@media (prefers-reduced-motion: reduce) { .atlas-repository, .visual-link, .visual-node, .repository-visual, .next-move-action { transition: none !important; }.atlas-repository.scanning::after, .repository-visual:not(.compact):hover .contour-one, .visual-node.hot { animation: none !important; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { transform: none; } }
|
||||
@media (max-width: 760px) { .command-pulse strong, .command-pulse span { display: none; } }
|
||||
|
||||
/* v1.4 remediation cockpit: decision provenance, layered surfaces and quiet technical motion. */
|
||||
.cockpit-page { gap: 18px; }.dashboard-command { isolation: isolate; min-height: 212px; border-color: rgba(139, 222, 215, .24); border-radius: 26px; background: radial-gradient(ellipse at 18% 4%, rgba(50, 131, 145, .25), transparent 43%), radial-gradient(ellipse at 79% 82%, rgba(105, 95, 165, .15), transparent 48%), linear-gradient(118deg, #102d40, #0b1d31 49%, #111d35); box-shadow: 0 30px 88px rgba(0, 0, 0, .31), inset 0 1px rgba(231, 255, 252, .07); }.dashboard-command::after { position: absolute; z-index: 0; top: -48px; left: 42%; width: 300px; height: 300px; border: 1px solid rgba(116, 218, 211, .11); border-radius: 50%; box-shadow: 0 0 0 34px rgba(116, 218, 211, .025), 0 0 0 78px rgba(116, 218, 211, .015); content: ''; pointer-events: none; }.command-copy { max-width: 565px; }.command-copy h1 { text-wrap: balance; }.command-trend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }.command-trend span { display: inline-flex; align-items: center; gap: 6px; border: 1px solid rgba(150, 189, 211, .16); border-radius: 999px; padding: 5px 8px; color: var(--text-muted); background: rgba(5, 17, 29, .28); font-size: 9px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }.command-trend i { width: 6px; height: 6px; border-radius: 50%; background: var(--ready); box-shadow: 0 0 0 3px rgba(100, 211, 155, .11); }.command-trend .trend-watch { background: var(--warning); box-shadow: 0 0 0 3px rgba(233, 187, 113, .11); }.command-context { backdrop-filter: blur(12px); box-shadow: inset 0 1px rgba(255, 255, 255, .035); }.command-context div { background: linear-gradient(160deg, rgba(13, 35, 52, .68), rgba(6, 17, 30, .47)); }
|
||||
.dashboard-readiness-rail { gap: 6px; overflow: visible; border: 0; border-radius: 0; background: transparent; }.readiness-rail-segment { position: relative; overflow: hidden; min-height: 99px; border: 1px solid rgba(132, 160, 193, .16); border-radius: 15px; background: linear-gradient(145deg, rgba(16, 35, 52, .88), rgba(8, 20, 34, .88)); box-shadow: inset 0 1px rgba(255,255,255,.025); }.readiness-rail-segment::after { position: absolute; right: 12px; bottom: 11px; width: 30px; height: 1px; background: currentColor; content: ''; opacity: .34; transform: rotate(-38deg); transform-origin: right; }.readiness-rail-segment:hover:not(:disabled) { border-color: rgba(117, 209, 204, .42); border-radius: 15px; box-shadow: 0 13px 24px rgba(0, 0, 0, .18); transform: translateY(-2px); }.readiness-rail-segment.blocked { color: var(--blocked); }.readiness-rail-segment.warnings { color: var(--warning); }.readiness-rail-segment.ready { color: var(--ready); }.readiness-rail-segment.all { color: var(--accent); }.readiness-rail-segment > span, .readiness-rail-segment small { color: var(--text-subtle); }.readiness-rail-segment strong { color: currentColor !important; }
|
||||
.cockpit-focus-grid { align-items: stretch; gap: 18px; }.dashboard-section { border-radius: 18px; box-shadow: inset 0 1px rgba(255, 255, 255, .025), 0 15px 35px rgba(0, 0, 0, .1); }.attention-section { position: relative; overflow: hidden; padding: 22px; background: linear-gradient(145deg, rgba(15, 34, 52, .94), rgba(8, 20, 33, .91)); }.attention-section::before { position: absolute; top: 0; bottom: 0; left: 0; width: 3px; background: linear-gradient(var(--blocked), rgba(237, 113, 136, 0)); content: ''; opacity: .78; }.attention-list { gap: 5px; margin-top: 7px; }.attention-row { position: relative; grid-template-columns: 26px 52px minmax(0, 1fr) auto; min-height: 66px; overflow: hidden; border-color: rgba(132, 160, 193, .07); padding: 8px 10px; background: linear-gradient(90deg, rgba(7, 18, 30, .44), transparent); }.attention-row::before { position: absolute; top: 12px; bottom: 12px; left: 0; width: 2px; border-radius: 99px; background: var(--blocked); content: ''; opacity: .55; }.attention-row:hover:not(:disabled), .attention-row:focus-visible { border-color: rgba(117, 209, 204, .34); background: linear-gradient(90deg, rgba(117, 209, 204, .1), rgba(24, 52, 65, .12)); }.attention-copy { display: grid; align-content: center; gap: 3px; }.attention-copy small { line-height: 1.35; }.attention-context { display: flex; align-items: center; gap: 7px; min-width: 0; }.attention-context i, .next-move-context i { max-width: 116px; overflow: hidden; border: 1px solid rgba(117, 209, 204, .2); border-radius: 999px; padding: 2px 5px; color: var(--accent-bright); background: rgba(117, 209, 204, .07); font-size: 8px; font-style: normal; font-weight: 720; letter-spacing: .04em; text-overflow: ellipsis; white-space: nowrap; }.attention-context em { overflow: hidden; margin: 0; color: var(--text-subtle); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.attention-score { display: grid; justify-items: end; align-content: center; gap: 3px; }.attention-score b { color: var(--blocked); font-size: 23px; }.attention-score small { color: var(--text-subtle); font-size: 8px; white-space: nowrap; }
|
||||
.next-move { border-color: rgba(117, 209, 204, .25); background: radial-gradient(ellipse at 84% 8%, rgba(77, 185, 178, .22), transparent 44%), linear-gradient(150deg, rgba(18, 52, 64, .96), rgba(8, 19, 33, .98)); }.next-move::before { position: absolute; top: 0; right: 0; width: 52%; height: 1px; background: linear-gradient(90deg, transparent, rgba(117, 209, 204, .72)); content: ''; }.next-move-action { border-radius: 13px; background: linear-gradient(125deg, rgba(5, 19, 30, .6), rgba(23, 50, 62, .35)); box-shadow: inset 0 1px rgba(255,255,255,.035); }.next-move-action:hover:not(:disabled) { box-shadow: 0 17px 28px rgba(0, 0, 0, .2), inset 0 1px rgba(255,255,255,.05); }.next-move-context { display: grid; gap: 5px; margin-top: 10px; color: var(--text-muted); font-size: 9px; font-style: normal; line-height: 1.4; }.next-move-context i { width: fit-content; }
|
||||
.portfolio-atlas { padding: 22px; background: linear-gradient(145deg, rgba(13, 31, 48, .92), rgba(7, 18, 30, .9)); }.atlas-field { border-radius: 19px; background: radial-gradient(ellipse at 78% 0, rgba(77, 166, 175, .17), transparent 47%), radial-gradient(ellipse at 12% 100%, rgba(105, 80, 140, .09), transparent 49%), rgba(4, 15, 26, .74); }.atlas-bands { gap: 10px; padding: 10px; background: transparent; }.atlas-band { grid-template-columns: 196px minmax(0, 1fr); min-height: 104px; overflow: hidden; border: 1px solid rgba(144, 184, 207, .12); border-radius: 14px; background: linear-gradient(90deg, rgba(13, 34, 50, .92), rgba(5, 17, 29, .62)); }.atlas-band > header { position: relative; gap: 4px; overflow: hidden; border-right: 1px solid rgba(135, 174, 200, .1); padding: 16px 17px 15px 21px; background: linear-gradient(100deg, rgba(20, 49, 66, .92), rgba(12, 29, 43, .22)); }.atlas-lane-beacon { position: absolute; top: 16px; left: 10px; width: 3px; height: 32px; border-radius: 99px; background: var(--accent); box-shadow: 0 0 17px currentColor; }.atlas-band.blocked .atlas-lane-beacon { color: var(--blocked); background: var(--blocked); }.atlas-band.warnings .atlas-lane-beacon { color: var(--warning); background: var(--warning); }.atlas-band.ready .atlas-lane-beacon { color: var(--ready); background: var(--ready); }.atlas-band > header strong { font-size: 31px; }.atlas-river { position: relative; min-width: 0; overflow: hidden; }.atlas-river::before { position: absolute; top: 50%; right: 0; left: 0; height: 1px; background: linear-gradient(90deg, rgba(117, 209, 204, .04), rgba(117, 209, 204, .25), rgba(117, 209, 204, .03)); content: ''; }.atlas-tiles { position: relative; grid-template-columns: repeat(auto-fill, minmax(61px, 1fr)); gap: 8px; padding: 13px 14px; }.atlas-repository { height: 62px; border-radius: 12px; background: linear-gradient(150deg, rgba(35, 69, 87, .86), rgba(10, 26, 42, .92)); box-shadow: inset 0 1px rgba(255,255,255,.045), 0 7px 12px rgba(0,0,0,.12); transform: translateY(calc((var(--signal-phase) - 3) * 1px)); transition: transform var(--motion) var(--ease), border-color var(--motion) var(--ease), background var(--motion) var(--ease), box-shadow var(--motion) var(--ease); }.atlas-repository::before { background: linear-gradient(135deg, rgba(255,255,255,.11), transparent 46%); }.atlas-repository-orbit { position: absolute; top: 8px; right: 7px; width: 15px; height: 15px; border: 1px solid currentColor; border-radius: 50%; opacity: .18; }.atlas-repository-orbit::after { position: absolute; top: 2px; right: -2px; width: 3px; height: 3px; border-radius: 50%; background: currentColor; content: ''; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { border-color: currentColor; box-shadow: 0 0 0 3px rgba(117, 209, 204, .11), 0 16px 24px rgba(0, 0, 0, .28); transform: translateY(-4px) scale(1.035); }.atlas-repository-name { padding-right: 12px; font-size: 9px; }.atlas-repository > i { height: 3px; }.portfolio-atlas[data-density='dense'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(47px, 1fr)); gap: 5px; padding: 10px; }.portfolio-atlas[data-density='dense'] .atlas-repository { height: 49px; border-radius: 9px; }.portfolio-atlas[data-density='compact'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(30px, 1fr)); gap: 3px; padding: 8px; }.portfolio-atlas[data-density='compact'] .atlas-repository { height: 33px; border-radius: 6px; transform: none; }.portfolio-atlas[data-density='compact'] .atlas-repository-orbit { display: none; }
|
||||
.repository-visual { border-radius: 14px; background: radial-gradient(ellipse at 78% 18%, rgba(105, 193, 187, .18), transparent 54%), linear-gradient(140deg, #0b2031, #081624); box-shadow: inset 0 1px rgba(255, 255, 255, .045); }.visual-gridline { stroke-dasharray: 2 5; }.visual-orbit { transform-origin: center; }.visual-core-beacon { filter: drop-shadow(0 0 5px currentColor); }.repository-visual:not(.compact):hover .orbit-one { animation: visual-orbit-spin 8s linear infinite; }.repository-visual:not(.compact):hover .orbit-two { animation: visual-orbit-spin 12s linear infinite reverse; }.workspace-hero { position: relative; isolation: isolate; border-color: rgba(132, 184, 207, .22); border-radius: 22px; background: radial-gradient(ellipse at 72% 15%, rgba(84, 177, 174, .17), transparent 39%), linear-gradient(120deg, rgba(16, 40, 56, .97), rgba(10, 23, 38, .97)); box-shadow: 0 24px 54px rgba(0,0,0,.18); }.workspace-hero::before { position: absolute; z-index: 0; top: -65px; right: 130px; width: 230px; height: 230px; border: 1px solid rgba(117, 209, 204, .1); border-radius: 50%; box-shadow: 0 0 0 28px rgba(117, 209, 204, .02); content: ''; }.workspace-hero > * { position: relative; z-index: 1; }.workspace-score { min-width: 100px; padding: 12px 0; border-right: 1px solid rgba(132, 184, 207, .13); }.workspace-score strong { text-shadow: 0 0 20px rgba(117, 209, 204, .25); }.workspace-visual { border-color: rgba(117, 209, 204, .26); box-shadow: 0 16px 30px rgba(0,0,0,.2); }.repository-tile { border-radius: 16px; background: linear-gradient(145deg, rgba(17, 37, 55, .92), rgba(8, 19, 32, .9)); }.repository-tile-main:hover:not(:disabled) { background: radial-gradient(ellipse at 80% 10%, rgba(117,209,204,.12), transparent 50%); }
|
||||
@keyframes visual-orbit-spin { to { transform: rotate(360deg); } }
|
||||
@media (max-width: 1180px) { .atlas-band { grid-template-columns: 168px minmax(0, 1fr); }.command-trend { margin-bottom: 2px; } }
|
||||
@media (max-width: 760px) { .dashboard-command { min-height: 0; }.command-trend { margin-top: 12px; }.dashboard-readiness-rail { gap: 7px; }.readiness-rail-segment:hover:not(:disabled) { transform: none; }.attention-section { padding: 17px; }.attention-row { grid-template-columns: 20px 44px minmax(0, 1fr) auto; min-height: 62px; gap: 8px; }.attention-score small { display: none; }.attention-context em { font-size: 8px; }.atlas-bands { gap: 7px; padding: 7px; }.atlas-band { grid-template-columns: 1fr; }.atlas-band > header { padding: 12px 14px 12px 19px; }.atlas-lane-beacon { top: 14px; left: 9px; height: 25px; }.atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(50px, 1fr)); gap: 5px; padding: 10px; }.atlas-repository { height: 50px; border-radius: 9px; transform: none; }.workspace-score { border-right: 0; border-bottom: 1px solid rgba(132, 184, 207, .13); }.workspace-hero::before { right: -45px; }.next-move-context { font-size: 8px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .readiness-rail-segment, .atlas-repository { transition: none !important; }.repository-visual:not(.compact):hover .orbit-one, .repository-visual:not(.compact):hover .orbit-two { animation: none !important; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { transform: none; } }
|
||||
|
||||
.overview-intelligence { display: grid; grid-template-columns: minmax(0, .83fr) minmax(0, 1.17fr); gap: 14px; }.change-brief > .muted { margin: 14px 0 2px; font-size: 11px; }.change-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin: 14px -18px -18px; border-top: 1px solid var(--border); background: var(--border); }.change-grid span { display: grid; gap: 4px; padding: 12px; color: var(--text-subtle); background: rgba(7, 17, 28, .28); font-size: 9px; text-transform: uppercase; }.change-grid b { color: var(--accent-bright); font-size: 17px; letter-spacing: -.04em; text-transform: none; }.top-findings-list { display: grid; gap: 2px; }.top-findings-list > div { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 9px; border-radius: 7px; padding: 7px 4px; }.top-findings-list > div:hover { background: rgba(117, 209, 204, .055); }.top-findings-list strong, .top-findings-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.top-findings-list strong { color: var(--text); font-size: 11px; }.top-findings-list small { margin-top: 2px; color: var(--text-subtle); font-size: 9px; }.top-findings-list b { color: var(--blocked); font-size: 11px; }
|
||||
.architecture-workspace { padding: 0; overflow: hidden; }.architecture-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--border); padding: 12px 15px; color: var(--text-subtle); font-size: 10px; }.architecture-toolbar label { width: min(360px, 100%); }.architecture-toolbar input { min-height: 35px; }.architecture-main { display: grid; grid-template-columns: minmax(0, 1fr) 230px; min-height: 590px; }.architecture-workspace .architecture-canvas { height: auto; min-height: 590px; border: 0; border-radius: 0; }.architecture-inspector { border-left: 1px solid var(--border); padding: 17px; background: rgba(5, 14, 24, .35); }.architecture-inspector h3 { margin: 6px 0; color: var(--text); font-size: 15px; }.architecture-inspector > code { display: block; margin-bottom: 16px; overflow-wrap: anywhere; color: var(--accent-bright); font-size: 10px; }.architecture-inspector p { color: var(--text-muted); font-size: 11px; line-height: 1.5; }.architecture-inspector dl { display: grid; gap: 8px; margin: 0; }.architecture-inspector dl > div { display: flex; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding-bottom: 7px; }.architecture-inspector dt { color: var(--text-subtle); font-size: 10px; }.architecture-inspector dd { margin: 0; color: var(--text); font-size: 11px; }.react-flow__node.arch-selected { border-color: var(--accent) !important; box-shadow: 0 0 0 2px rgba(117, 209, 204, .14), 0 10px 28px rgba(0, 0, 0, .3) !important; }.react-flow__edge.animated .react-flow__edge-path { stroke: var(--accent) !important; }
|
||||
@media (max-width: 760px) { .overview-intelligence { grid-template-columns: 1fr; }.change-grid { grid-template-columns: 1fr 1fr; }.architecture-main { grid-template-columns: 1fr; }.architecture-workspace .architecture-canvas { min-height: 460px; }.architecture-inspector { min-height: 160px; border-top: 1px solid var(--border); border-left: 0; }.architecture-toolbar { align-items: stretch; flex-direction: column; }.architecture-toolbar label { width: 100%; } }
|
||||
|
||||
/* v1.5: compact remediation queue driven only by retained backend evidence. */
|
||||
.remediation-center { padding: 19px; background: linear-gradient(135deg, rgba(15, 38, 53, .94), rgba(8, 20, 34, .9)); }.remediation-live { color: var(--accent-bright); font-size: 9px; font-weight: 720; letter-spacing: .08em; text-transform: uppercase; }.remediation-list { display: grid; gap: 5px; margin: 10px 0 0; padding: 0; list-style: none; }.remediation-row { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; border: 1px solid rgba(132, 160, 193, .12); border-radius: 11px; padding: 11px; color: var(--text); background: rgba(5, 17, 29, .34); text-align: left; transition: border-color var(--motion) var(--ease), background var(--motion) var(--ease); }.remediation-row:hover:not(:disabled), .remediation-row:focus-visible { border-color: rgba(117, 209, 204, .42); background: rgba(117, 209, 204, .075); }.remediation-priority { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid rgba(237, 113, 136, .34); border-radius: 50%; color: var(--blocked); background: rgba(237, 113, 136, .09); font-size: 11px; font-weight: 800; }.remediation-copy { display: grid; min-width: 0; gap: 3px; }.remediation-copy strong, .remediation-copy small, .remediation-copy em, .remediation-copy b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.remediation-copy strong { font-size: 12px; }.remediation-copy small { color: var(--accent-bright); font-size: 9px; text-transform: capitalize; }.remediation-copy em { color: var(--text-muted); font-size: 10px; font-style: normal; }.remediation-copy b { color: var(--text-subtle); font-size: 9px; font-weight: 500; }.remediation-arrow { color: var(--accent); font-size: 18px; }.remediation-row:hover .remediation-arrow { transform: translateX(2px); }
|
||||
@media (max-width: 760px) { .remediation-center { padding: 15px; }.remediation-row { grid-template-columns: 34px minmax(0, 1fr) auto; gap: 9px; padding: 9px; }.remediation-priority { width: 31px; height: 31px; }.remediation-copy b { display: none; } }
|
||||
.repository-table th { position: static; }
|
||||
.repository-table tbody tr { scroll-margin-top: 150px; }
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { AppMeta, RepositoryDetail, RepositoryPolicy, RepositorySummary, ScanComparison, ScanReport, ScanSummary, TrendPoint } from './api'
|
||||
|
||||
export const testPolicy: RepositoryPolicy = {
|
||||
auto_scan: true,
|
||||
min_health_score: 70,
|
||||
max_blockers: 0,
|
||||
require_tests: true,
|
||||
require_ci: true,
|
||||
suppressions: [],
|
||||
}
|
||||
|
||||
export function testRepository(id = 'gitea:64', fullName = 'Jens/RepoPulse', overrides: Partial<RepositorySummary> = {}): RepositorySummary {
|
||||
const [owner, name] = fullName.split('/')
|
||||
return {
|
||||
id,
|
||||
provider: id.startsWith('local:') ? 'local' : 'gitea',
|
||||
provider_repository_id: id.split(':')[1] ?? id,
|
||||
owner: owner ?? 'Jens',
|
||||
name: name ?? fullName,
|
||||
full_name: fullName,
|
||||
description: 'Test repository',
|
||||
html_url: null,
|
||||
clone_url: null,
|
||||
default_branch: 'main',
|
||||
private: true,
|
||||
archived: false,
|
||||
available: true,
|
||||
latest_revision: 'revision-1',
|
||||
latest_revision_at: '2026-09-12T20:00:00Z',
|
||||
last_synced_at: '2026-09-12T20:00:00Z',
|
||||
sync_error: null,
|
||||
state: 'current',
|
||||
stale: false,
|
||||
active_job_id: null,
|
||||
last_scan_id: 'scan-1',
|
||||
last_scan_at: '2026-09-12T20:00:00Z',
|
||||
last_scan_health: 82,
|
||||
last_scan_readiness: 'BLOCKED',
|
||||
last_scan_revision: 'revision-1',
|
||||
last_scan_blockers: 1,
|
||||
last_scan_warnings: 0,
|
||||
last_scan_primary_signal: { code: 'security.secret-assignment', category: 'Security basics', severity: 'blocker', message: 'Potential credential assignment detected; value intentionally redacted', count: 1 },
|
||||
last_scan_health_delta: null,
|
||||
last_scan_added_findings: 0,
|
||||
last_scan_resolved_findings: 0,
|
||||
policy: testPolicy,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function testReport(overrides: Partial<ScanReport> = {}): ScanReport {
|
||||
return {
|
||||
id: 'scan-1',
|
||||
repository_path: '/scan/repopulse',
|
||||
repository_name: 'RepoPulse',
|
||||
source: {
|
||||
origin: 'gitea',
|
||||
provider: 'gitea',
|
||||
repository_id: '64',
|
||||
repository_full_name: 'Jens/RepoPulse',
|
||||
branch: 'main',
|
||||
requested_revision: 'revision-1',
|
||||
actual_revision: 'revision-1',
|
||||
trigger: 'scheduled',
|
||||
},
|
||||
policy: testPolicy,
|
||||
scanned_at: '2026-09-12T20:00:00Z',
|
||||
duration_ms: 1200,
|
||||
metrics: {
|
||||
files: 2,
|
||||
source_files: 2,
|
||||
lines_of_code: 20,
|
||||
test_files: 1,
|
||||
todo: 0,
|
||||
fixme: 0,
|
||||
hack: 0,
|
||||
xxx: 0,
|
||||
dependency_manifests: 1,
|
||||
declared_dependencies: 1,
|
||||
large_files: 0,
|
||||
large_source_files: 0,
|
||||
},
|
||||
languages: { TypeScript: 20 },
|
||||
category_scores: { 'Security basics': 20, Maintainability: 80 },
|
||||
health_score: 70,
|
||||
release_readiness: 'BLOCKED',
|
||||
findings: [{
|
||||
code: 'security.secret-assignment',
|
||||
category: 'Security basics',
|
||||
severity: 'blocker',
|
||||
message: 'Potential credential assignment detected; value intentionally redacted',
|
||||
path: 'backend/app/main.py',
|
||||
line: 245,
|
||||
deduction: 20,
|
||||
fingerprint: 'fingerprint-1',
|
||||
suppressed: false,
|
||||
}],
|
||||
tests: { frameworks: ['pytest'], coverage_artifacts: [], ci_workflows: [], test_source_ratio: null },
|
||||
git: { available: true, commit_count: 1, contributor_count: 1, latest_commit_at: null, tracked_files: 2, renamed_files: [] },
|
||||
dependencies: { manifests: ['package.json'], lockfiles: ['package-lock.json'], declared_dependencies: 1, unbounded_dependencies: 0, unpinned_dependencies: 0, duplicate_dependencies: [] },
|
||||
architecture: { nodes: [], edges: [], clusters: {}, truncated: false, total_nodes: 0, total_edges: 0 },
|
||||
hotspots: [],
|
||||
release_gate: { status: 'BLOCKED', checks: [{ key: 'security', label: 'Security basics', state: 'block', detail: '1 blocker finding' }] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export const testComparison: ScanComparison = {
|
||||
current_scan_id: 'scan-1',
|
||||
previous_scan_id: null,
|
||||
health_score_delta: null,
|
||||
category_score_deltas: {},
|
||||
metric_deltas: {},
|
||||
added_findings: [],
|
||||
resolved_findings: [],
|
||||
}
|
||||
|
||||
export const testScans: ScanSummary[] = [{
|
||||
id: 'scan-1',
|
||||
repository_id: 'gitea:64',
|
||||
scanned_at: '2026-09-12T20:00:00Z',
|
||||
branch: 'main',
|
||||
requested_revision: 'revision-1',
|
||||
actual_revision: 'revision-1',
|
||||
trigger: 'scheduled',
|
||||
health_score: 70,
|
||||
release_readiness: 'BLOCKED',
|
||||
findings: 1,
|
||||
duration_ms: 1200,
|
||||
}]
|
||||
|
||||
export const testTrend: TrendPoint[] = []
|
||||
|
||||
export const testMeta: AppMeta = {
|
||||
name: 'RepoPulse',
|
||||
version: '1.5.0',
|
||||
stage: 'v1.5.0',
|
||||
scan_root_restricted: true,
|
||||
scan_root: '/scan',
|
||||
max_concurrent_scans: 1,
|
||||
repository_count: 2,
|
||||
repository_states: { current: 2 },
|
||||
current_repositories: 2,
|
||||
stale_repositories: 0,
|
||||
queue_depth: 0,
|
||||
active_jobs: 0,
|
||||
completed_jobs: 3,
|
||||
failed_jobs: 0,
|
||||
worker_heartbeat: '2026-09-12T20:00:00Z',
|
||||
gitea: { configured: true, url: 'http://gitea.test', last_sync: { synced_at: '2026-09-12T20:00:00Z' } },
|
||||
}
|
||||
|
||||
export function testDetail(repository = testRepository()): RepositoryDetail {
|
||||
return { ...repository, latest_scan: testReport() }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { configDefaults, defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const environment = loadEnv(mode, '.', '')
|
||||
const apiTarget = environment.REPOPULSE_API_PROXY || 'http://localhost:8000'
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': apiTarget,
|
||||
'/health': apiTarget,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "repopulse-workspace",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "repopulse-workspace",
|
||||
"version": "1.5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "repopulse-workspace",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"scripts": {
|
||||
"test": "npm --prefix frontend run test",
|
||||
"typecheck": "npm --prefix frontend run typecheck",
|
||||
"build": "npm --prefix frontend run build"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
-e ./backend[dev]
|
||||