Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87d44537b9 | |||
| 101deb9546 | |||
| 80fc813b61 | |||
| 0d3a5fe2fe |
@@ -0,0 +1,17 @@
|
|||||||
|
# Copy to deploy/.env.staging and fill in. Used by:
|
||||||
|
# docker compose -p inboxintel-staging --env-file deploy/.env.staging \
|
||||||
|
# -f docker-compose.yml -f docker-compose.staging.yml up
|
||||||
|
# (or ./deploy/up.ps1 -Staging). Kept separate from deploy/.env (production) so
|
||||||
|
# staging can never touch prod credentials, DB, or Google project.
|
||||||
|
POSTGRES_PASSWORD=change-me-staging
|
||||||
|
|
||||||
|
# Use a SEPARATE Google OAuth client for staging with redirect URI:
|
||||||
|
# http://localhost:18081/signin-google
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
AI_MODE=Disabled
|
||||||
|
FRONTEND_ORIGIN=http://localhost:18081
|
||||||
|
|
||||||
|
# Staging caps the initial mailbox sync so rehearsals are fast.
|
||||||
|
MAX_MESSAGES=2000
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Deploy Production
|
||||||
|
|
||||||
|
# Production promotion. The APPROVAL GATE is the git tag: production only ever
|
||||||
|
# deploys a tagged release cut on main (see docs/WORKFLOW.md §5). Cutting the tag
|
||||||
|
# is the deliberate, auditable "approve to go live" action — and the tag doubles
|
||||||
|
# as the rollback target. workflow_dispatch adds a manual "Run workflow" button
|
||||||
|
# for re-deploys/rollbacks.
|
||||||
|
#
|
||||||
|
# STATUS: inactive until (a) the Linux production server exists and (b) a
|
||||||
|
# self-hosted Gitea runner is registered on it with labels [self-hosted, production].
|
||||||
|
# Tag pushes before then will queue harmlessly. This file is the wiring, ready to
|
||||||
|
# switch on — review the deploy step for your server before first use.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: 'Tag or commit to deploy (e.g. v1.2.0)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: [self-hosted, production]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Deploy the exact tag that triggered the run (immutable), or the
|
||||||
|
# ref given to a manual dispatch.
|
||||||
|
ref: ${{ github.event.inputs.ref || github.ref_name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Deploy release to production
|
||||||
|
run: |
|
||||||
|
echo "Deploying ${{ github.event.inputs.ref || github.ref_name }} to production"
|
||||||
|
# deploy/.env lives on the server (never in git). up.sh validates it,
|
||||||
|
# builds the Linux images, applies EF migrations on boot, and starts
|
||||||
|
# the stack behind the nginx reverse proxy.
|
||||||
|
./deploy/up.sh --proxy
|
||||||
|
|
||||||
|
- name: Smoke check
|
||||||
|
run: |
|
||||||
|
sleep 5
|
||||||
|
curl -fsS http://localhost/ >/dev/null && echo "Prod responding on :80" || \
|
||||||
|
{ echo "Smoke check failed"; exit 1; }
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Deploy Staging
|
||||||
|
|
||||||
|
# Continuous deployment to the LOCAL staging stack. Fires when develop advances
|
||||||
|
# (i.e. after a PR is merged into develop). It re-verifies the code, then rebuilds
|
||||||
|
# and restarts the isolated staging stack on this machine.
|
||||||
|
#
|
||||||
|
# CRITICAL: staging lives on your Windows box (ports 18080/18081). A cloud or
|
||||||
|
# container runner CANNOT reach it, so this job MUST run on a self-hosted Gitea
|
||||||
|
# Actions runner registered ON that Windows machine with Docker access
|
||||||
|
# (labels: self-hosted, windows). Until that runner exists this job just waits
|
||||||
|
# in the queue (harmless, cancelable) — deploy staging manually meanwhile with:
|
||||||
|
# ./deploy/up.ps1 -Staging
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
workflow_dispatch: {} # also allow a manual "Run workflow" from the Gitea UI
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: [self-hosted, windows]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Re-run the gate on the exact merged code before it touches staging.
|
||||||
|
- name: Build + test (Release)
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
dotnet build InboxIntel.sln -c Release --nologo
|
||||||
|
dotnet test InboxIntel.sln -c Release --no-build --nologo
|
||||||
|
|
||||||
|
- name: Redeploy staging stack
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
docker compose -p inboxintel-staging `
|
||||||
|
--env-file deploy/.env.staging `
|
||||||
|
-f docker-compose.yml -f docker-compose.staging.yml `
|
||||||
|
up -d --build
|
||||||
|
docker compose -p inboxintel-staging ps
|
||||||
|
|
||||||
|
- name: Staging endpoints
|
||||||
|
shell: powershell
|
||||||
|
run: Write-Host "Staging up — Frontend http://localhost:18081 API http://localhost:18080/swagger"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
# Scans run alongside CI on every PR and on pushes to the long-lived branches.
|
||||||
|
# This is the DETECTIVE layer (backstop). The PREVENTIVE layer is the local
|
||||||
|
# pre-commit hook — this catches anything that slipped past it (e.g. --no-verify)
|
||||||
|
# and scans the full history, not just the staged diff.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
secrets:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # full history so gitleaks scans every commit
|
||||||
|
- name: Secret scan (gitleaks)
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \
|
||||||
|
detect --source=/repo --redact --verbose --exit-code 1
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore InboxIntel.sln
|
||||||
|
- name: .NET vulnerable packages (fail on any)
|
||||||
|
run: |
|
||||||
|
dotnet list InboxIntel.sln package --vulnerable --include-transitive 2>&1 | tee vuln.txt
|
||||||
|
if grep -qiE 'Critical|High|Moderate|Low' vuln.txt; then
|
||||||
|
echo "::error::Vulnerable NuGet packages detected — see the table above."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "No vulnerable NuGet packages."
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- name: npm audit (fail on high/critical)
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm audit --audit-level=high
|
||||||
@@ -54,6 +54,7 @@ lpt[1-9].*
|
|||||||
*.code-workspace
|
*.code-workspace
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.staging.example
|
||||||
.next/
|
.next/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to InboxIntel are documented here. Format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/); versions follow
|
||||||
|
[Semantic Versioning](https://semver.org/). See [docs/WORKFLOW.md](docs/WORKFLOW.md).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- CI/CD pipeline (`.gitea/workflows/`): `security` (gitleaks secret scan + NuGet/npm
|
||||||
|
vulnerability gate), `deploy-staging` (auto-redeploy local staging on `develop`),
|
||||||
|
`deploy-prod` (tag-gated production promotion, inactive until the server exists).
|
||||||
|
- Formal Git workflow & environment strategy (`docs/WORKFLOW.md`).
|
||||||
|
- Staging environment overlay (`docker-compose.staging.yml`) — production-shaped
|
||||||
|
Linux containers on Windows, isolated ports/volumes.
|
||||||
|
- Version-controlled Git hooks (`scripts/git-hooks/`) + installer
|
||||||
|
(`scripts/install-hooks.ps1`): pre-commit secret/format checks, pre-push
|
||||||
|
build+test gate.
|
||||||
|
- `VERSION` file as the single source of truth for the release number.
|
||||||
|
|
||||||
|
## [0.1.0] — scaffold
|
||||||
|
### Added
|
||||||
|
- .NET 8 Clean Architecture backend (Domain/Application/Infrastructure/Api) + React/Vite SPA.
|
||||||
|
- Docker Compose stack (Postgres 16, API, frontend, optional nginx proxy).
|
||||||
|
- Gitea Actions CI (backend build+test, frontend build) on `main`/`develop` + PRs.
|
||||||
|
- Security hardening: encrypted OAuth tokens, EF global query filters (IDOR),
|
||||||
|
loopback binds, non-root containers, SSRF egress guard.
|
||||||
|
- One-command deploy scripts (`deploy/up.ps1`, `deploy/up.sh`).
|
||||||
|
|
||||||
|
[Unreleased]: https://your-gitea-host/InboxIntel/compare/v0.1.0...HEAD
|
||||||
+13
-4
@@ -5,13 +5,22 @@
|
|||||||
./deploy/down.ps1
|
./deploy/down.ps1
|
||||||
./deploy/down.ps1 -Volumes
|
./deploy/down.ps1 -Volumes
|
||||||
#>
|
#>
|
||||||
param([switch]$Volumes)
|
param([switch]$Volumes, [switch]$Staging)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = Split-Path -Parent $PSScriptRoot
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
$envFile = Join-Path $PSScriptRoot '.env'
|
|
||||||
|
|
||||||
$composeArgs = @('compose', '--env-file', $envFile, 'down')
|
if ($Staging) {
|
||||||
|
$envFile = Join-Path $PSScriptRoot '.env.staging'
|
||||||
|
$composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
|
||||||
|
$project = @('-p', 'inboxintel-staging')
|
||||||
|
} else {
|
||||||
|
$envFile = Join-Path $PSScriptRoot '.env'
|
||||||
|
$composeFiles = @()
|
||||||
|
$project = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
$composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles + @('down')
|
||||||
if ($Volumes) { $composeArgs += '--volumes' }
|
if ($Volumes) { $composeArgs += '--volumes' }
|
||||||
|
|
||||||
Push-Location $root
|
Push-Location $root
|
||||||
|
|||||||
+27
-10
@@ -8,15 +8,28 @@
|
|||||||
#>
|
#>
|
||||||
param(
|
param(
|
||||||
[switch]$Proxy,
|
[switch]$Proxy,
|
||||||
[switch]$Foreground
|
[switch]$Foreground,
|
||||||
|
[switch]$Staging # production-shaped staging stack: separate env, ports, volumes
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
|
$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
|
||||||
$envFile = Join-Path $PSScriptRoot '.env'
|
|
||||||
|
|
||||||
if (-not (Test-Path $envFile)) {
|
# Staging vs production: pick the env file + compose overlay + isolated project name.
|
||||||
throw "Missing $envFile. Create it from .env.example with your real secrets."
|
if ($Staging) {
|
||||||
|
$envFile = Join-Path $PSScriptRoot '.env.staging'
|
||||||
|
$composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
|
||||||
|
$project = @('-p', 'inboxintel-staging')
|
||||||
|
if (-not (Test-Path $envFile)) {
|
||||||
|
throw "Missing $envFile. Create it from .env.staging.example."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$envFile = Join-Path $PSScriptRoot '.env'
|
||||||
|
$composeFiles = @()
|
||||||
|
$project = @()
|
||||||
|
if (-not (Test-Path $envFile)) {
|
||||||
|
throw "Missing $envFile. Create it from .env.example with your real secrets."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fail fast if a required key is absent or blank.
|
# Fail fast if a required key is absent or blank.
|
||||||
@@ -28,19 +41,23 @@ Get-Content $envFile | ForEach-Object {
|
|||||||
$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) }
|
$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) }
|
||||||
if ($missing) { throw "deploy/.env is missing values for: $($missing -join ', ')" }
|
if ($missing) { throw "deploy/.env is missing values for: $($missing -join ', ')" }
|
||||||
|
|
||||||
$composeArgs = @('compose', '--env-file', $envFile)
|
$composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles
|
||||||
if ($Proxy) { $composeArgs += @('--profile', 'proxy') }
|
if ($Proxy) { $composeArgs += @('--profile', 'proxy') }
|
||||||
$composeArgs += @('up', '--build')
|
$composeArgs += @('up', '--build')
|
||||||
if (-not $Foreground) { $composeArgs += '-d' }
|
if (-not $Foreground) { $composeArgs += '-d' }
|
||||||
|
|
||||||
Push-Location $root
|
Push-Location $root
|
||||||
try {
|
try {
|
||||||
Write-Host "Starting InboxIntel via docker compose (env: deploy/.env)..." -ForegroundColor Cyan
|
Write-Host "Starting InboxIntel via docker compose (env: $envFile)..." -ForegroundColor Cyan
|
||||||
& docker @composeArgs
|
& docker @composeArgs
|
||||||
if (-not $Foreground) {
|
if (-not $Foreground) {
|
||||||
& docker compose --env-file $envFile ps
|
& docker compose @project --env-file $envFile @composeFiles ps
|
||||||
Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green
|
if ($Staging) {
|
||||||
Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1" -ForegroundColor DarkGray
|
Write-Host "`n[STAGING] Frontend: http://localhost:18081 API/Swagger: http://localhost:18080/swagger" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1$(if($Staging){' -Staging'})" -ForegroundColor DarkGray
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally { Pop-Location }
|
finally { Pop-Location }
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Staging overlay for InboxIntel.
|
||||||
|
#
|
||||||
|
# The base docker-compose.yml IS the production definition (Linux containers,
|
||||||
|
# ASPNETCORE_ENVIRONMENT=Production). This overlay layers a *staging* variant on
|
||||||
|
# top of it so you can run a production-shaped stack locally on Windows WITHOUT
|
||||||
|
# clobbering a real production deployment's data, ports, or volumes.
|
||||||
|
#
|
||||||
|
# It differs from prod only in the ways staging is meant to differ:
|
||||||
|
# - the dev/test banner is on (App__DevMode=true)
|
||||||
|
# - the initial Gmail sync is capped so a big mailbox doesn't take forever
|
||||||
|
# - ports are shifted into the 18xxx range so staging can run alongside prod
|
||||||
|
# - a distinct project name gives it its own isolated pgdata + keys volumes
|
||||||
|
#
|
||||||
|
# Run it with the -p (project name) flag so volumes/networks are namespaced:
|
||||||
|
#
|
||||||
|
# docker compose -p inboxintel-staging \
|
||||||
|
# --env-file deploy/.env.staging \
|
||||||
|
# -f docker-compose.yml -f docker-compose.staging.yml up --build -d
|
||||||
|
#
|
||||||
|
# (deploy/up.ps1 -Staging / up.sh --staging wrap this for you.)
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:15432:5432"
|
||||||
|
|
||||||
|
api:
|
||||||
|
environment:
|
||||||
|
# Same Production runtime as prod (real config binding, real build), but
|
||||||
|
# flagged as a non-production instance so the UI shows the staging banner
|
||||||
|
# and the first sync is bounded.
|
||||||
|
App__DevMode: "true"
|
||||||
|
GmailSync__MaxMessages: ${MAX_MESSAGES:-2000}
|
||||||
|
Cors__Origins__0: ${FRONTEND_ORIGIN:-http://localhost:18081}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:18080:8080"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "18081:80"
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
ports:
|
||||||
|
- "18000:80"
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# InboxIntel — Git Workflow & Environment Strategy
|
||||||
|
|
||||||
|
The single source of truth for how we branch, commit, version, and promote code
|
||||||
|
across environments. Optimised for a **solo developer on Windows 11 with a future
|
||||||
|
Linux production server**. Kept deliberately lightweight — every rule here earns
|
||||||
|
its place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Branch strategy
|
||||||
|
|
||||||
|
A trimmed **GitHub Flow + a long-lived `develop`** model. Two permanent branches,
|
||||||
|
short-lived branches off `develop`.
|
||||||
|
|
||||||
|
| Branch | Lives for | Purpose | Deploys to |
|
||||||
|
|-----------------|-----------|------------------------------------------------------|------------|
|
||||||
|
| `main` | forever | Always releasable. Every commit is tagged & shippable | production |
|
||||||
|
| `develop` | forever | Integration branch. What staging runs | staging |
|
||||||
|
| `feature/*` | hours–days| One feature or refactor | dev (local)|
|
||||||
|
| `fix/*` | hours | Non-urgent bug fix | dev (local)|
|
||||||
|
| `hotfix/*` | minutes–hrs| Urgent prod fix, branched from `main` | prod (fast)|
|
||||||
|
| `release/x.y.0` | optional | Only if a release needs stabilisation before tagging | staging |
|
||||||
|
|
||||||
|
**Why this shape (not full GitFlow):** a solo dev doesn't need GitFlow's ceremony
|
||||||
|
(separate release managers, parallel release trains). But keeping `develop`
|
||||||
|
separate from `main` gives one thing that pure trunk-based can't: a **staging
|
||||||
|
environment that always mirrors `develop`** while `main` stays clean and
|
||||||
|
tag-perfect for production. `release/*` exists only when you want to freeze
|
||||||
|
features and stabilise — skip it for routine work.
|
||||||
|
|
||||||
|
### Normal flow
|
||||||
|
|
||||||
|
```
|
||||||
|
main ──────●────────────────────●────────── (tagged: v1.2.0, v1.3.0)
|
||||||
|
\ /
|
||||||
|
develop ──●──●──●──●──●──●──●──●──────────── (staging)
|
||||||
|
\ / \ /
|
||||||
|
feature/x ●─● \ /
|
||||||
|
fix/y ●───●
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `git switch develop && git pull`
|
||||||
|
2. `git switch -c feature/sender-policy`
|
||||||
|
3. Commit in small conventional commits.
|
||||||
|
4. Push, open a PR **into `develop`** (Gitea). CI must be green.
|
||||||
|
5. Squash-merge. Delete the branch.
|
||||||
|
6. When `develop` is ready to ship → PR `develop → main`, tag, deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Commit conventions — Conventional Commits
|
||||||
|
|
||||||
|
Format: `type(scope): short imperative summary`
|
||||||
|
|
||||||
|
**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, `perf`,
|
||||||
|
`build`, `style`, `security`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Summary ≤ 72 chars, imperative mood ("add", not "added").
|
||||||
|
- One logical change per commit.
|
||||||
|
- Body explains **why**, not what (the diff shows what).
|
||||||
|
- Breaking change → add `!` (`feat!:`) and a `BREAKING CHANGE:` footer.
|
||||||
|
|
||||||
|
Examples (matching this repo's history):
|
||||||
|
```
|
||||||
|
feat(ui): F1+F2 — component primitives + rebuilt app shell
|
||||||
|
fix(security): systemic IDOR safeguard via EF global query filters
|
||||||
|
ci: add Gitea Actions build + test pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
Why: conventional commits drive **automatic semver bumps** and a generated
|
||||||
|
CHANGELOG, and make `git log` scannable. `feat` → minor, `fix` → patch,
|
||||||
|
`BREAKING CHANGE` → major.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pull request / merge flow (Gitea)
|
||||||
|
|
||||||
|
Even solo, PRs are worth it: they run CI, give a diff review checkpoint, and build
|
||||||
|
a paper trail.
|
||||||
|
|
||||||
|
- **Target:** `feature/*` and `fix/*` → `develop`. `develop`/`hotfix/*` → `main`.
|
||||||
|
- **Gate:** the `CI` workflow (backend build+test, frontend build) must pass.
|
||||||
|
- **Merge style:** **squash-merge** feature branches (one clean commit on
|
||||||
|
`develop`). **Merge commit** for `develop → main` (preserves the integration
|
||||||
|
history and makes the release boundary visible).
|
||||||
|
- **Branch protection (Gitea → Settings → Branches):** protect `main` and
|
||||||
|
`develop`; require status checks to pass; disallow force-push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Versioning — Semantic Versioning (`MAJOR.MINOR.PATCH`)
|
||||||
|
|
||||||
|
- **MAJOR** — breaking API/behaviour change.
|
||||||
|
- **MINOR** — backward-compatible feature.
|
||||||
|
- **PATCH** — backward-compatible fix.
|
||||||
|
|
||||||
|
The current version lives in [`VERSION`](../VERSION) and is the one place bumped
|
||||||
|
per release. Pre-1.0 while scaffolding: stay on `0.x` (minor = features, patch =
|
||||||
|
fixes; anything may change).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tagging & releases
|
||||||
|
|
||||||
|
Tags are cut **only on `main`**, annotated, prefixed `v`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git switch main && git pull
|
||||||
|
# bump VERSION + CHANGELOG in a release commit, then:
|
||||||
|
git tag -a v1.3.0 -m "v1.3.0 — sender policy + staging overlay"
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
- Tag == the exact commit deployed to production == the rollback target.
|
||||||
|
- The tag message summarises the release; details live in `CHANGELOG.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hotfix process
|
||||||
|
|
||||||
|
For a bug already in production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git switch main && git pull
|
||||||
|
git switch -c hotfix/oauth-callback-500
|
||||||
|
# fix + test
|
||||||
|
git commit -m "fix(security): guard null OAuth state on callback"
|
||||||
|
# PR hotfix/* -> main, CI green, merge
|
||||||
|
git switch main && git pull
|
||||||
|
git tag -a v1.3.1 -m "v1.3.1 hotfix — OAuth callback" && git push origin main --tags
|
||||||
|
# deploy the tag, THEN back-merge so develop doesn't lose the fix:
|
||||||
|
git switch develop && git merge main && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
The **back-merge to `develop`** is the step people forget — without it the next
|
||||||
|
release silently reintroduces the bug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Environments
|
||||||
|
|
||||||
|
| Env | Host | Runtime | Config source | Purpose |
|
||||||
|
|-------------|-------------------------|--------------------------------------|--------------------------|--------------------|
|
||||||
|
| Development | Windows 11 (native) | `dotnet run` + `vite` (hot reload) | `appsettings.Development.json` + user-secrets | fast iteration, debugging |
|
||||||
|
| Staging | Windows 11 (Docker) | Linux containers, `ASPNETCORE_ENVIRONMENT=Production` | `deploy/.env.staging` + `docker-compose.staging.yml` | production rehearsal |
|
||||||
|
| Production | Linux server (Docker) | identical Linux containers | `deploy/.env` on the server | live |
|
||||||
|
|
||||||
|
**Parity principle:** dev is fast (native, Windows) and *not* production-shaped —
|
||||||
|
that's fine, it's for inner-loop speed. **Staging is the parity gate**: it runs the
|
||||||
|
*same Linux images* Docker builds for production, so "works in staging" genuinely
|
||||||
|
predicts "works in prod". The only staging↔prod differences are the dev banner,
|
||||||
|
capped sync, ports, and volume namespace — see `docker-compose.staging.yml`.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# DEV (native, hot reload) — two terminals
|
||||||
|
dotnet run --project src/InboxIntel.Api # http://localhost:5080
|
||||||
|
cd frontend; npm run dev # http://localhost:5173
|
||||||
|
|
||||||
|
# STAGING (production-shaped, on Windows via Docker)
|
||||||
|
./deploy/up.ps1 -Staging # http://localhost:18081
|
||||||
|
./deploy/down.ps1 -Staging
|
||||||
|
|
||||||
|
# PRODUCTION-shaped run locally (smoke test the real config)
|
||||||
|
./deploy/up.ps1 # http://localhost:8081
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev and staging use **different ports and different volumes**, so they can run at
|
||||||
|
the same time and never share a database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Git hooks
|
||||||
|
|
||||||
|
Installed via `core.hooksPath` (run once per clone):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/install-hooks.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
- **pre-commit** (fast): blocks committed `.env`/secrets, verifies `dotnet format`.
|
||||||
|
- **pre-push** (thorough): `dotnet build -c Release` + `dotnet test` + frontend
|
||||||
|
build — the same gate CI runs, caught before the push.
|
||||||
|
|
||||||
|
Emergency bypass: `--no-verify`. CI still enforces the gate server-side, so a
|
||||||
|
bypassed push can still be rejected by branch protection.
|
||||||
@@ -38,6 +38,7 @@ export const AnalyticsApi = {
|
|||||||
health: () => api.get('/analytics/health').then((r) => r.data),
|
health: () => api.get('/analytics/health').then((r) => r.data),
|
||||||
topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data),
|
topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data),
|
||||||
volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data),
|
volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data),
|
||||||
|
heatmap: () => api.get('/analytics/heatmap').then((r) => r.data),
|
||||||
categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data),
|
categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data),
|
||||||
attachments: () => api.get('/analytics/attachments').then((r) => r.data),
|
attachments: () => api.get('/analytics/attachments').then((r) => r.data),
|
||||||
sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data),
|
sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data),
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Line, Doughnut } from 'react-chartjs-2';
|
import { Bar, Line, Doughnut } from 'react-chartjs-2';
|
||||||
import { Inbox } from 'lucide-react';
|
|
||||||
import { AnalyticsApi } from '../api/client.js';
|
import { AnalyticsApi } from '../api/client.js';
|
||||||
import { Skeleton } from './ui/skeleton.jsx';
|
|
||||||
import { EmptyState } from './ui/misc.jsx';
|
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
|
||||||
LineElement, ArcElement, Tooltip, Legend
|
LineElement, ArcElement, Tooltip, Legend
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend);
|
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend);
|
||||||
|
|
||||||
const fmtBytes = (b) => {
|
const fmtBytes = (b) => {
|
||||||
if (!b) return '0 B';
|
if (!b) return '0 B';
|
||||||
@@ -80,6 +77,32 @@ export function VolumeWidget({ volume }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function HeatmapWidget({ heatmap }) {
|
||||||
|
if (!heatmap) return <Empty />;
|
||||||
|
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const max = Math.max(1, ...heatmap.map((c) => c.count));
|
||||||
|
const grid = {};
|
||||||
|
heatmap.forEach((c) => { grid[`${c.dayOfWeek}-${c.hour}`] = c.count; });
|
||||||
|
return (
|
||||||
|
<div className="widget">
|
||||||
|
<h3>Activity Heatmap</h3>
|
||||||
|
<div className="heatmap">
|
||||||
|
{days.map((d, dow) => (
|
||||||
|
<div className="hm-row" key={dow}>
|
||||||
|
<span className="hm-day">{d}</span>
|
||||||
|
{Array.from({ length: 24 }, (_, h) => {
|
||||||
|
const v = grid[`${dow}-${h}`] || 0;
|
||||||
|
return <span key={h} className="hm-cell" style={{ opacity: 0.1 + 0.9 * (v / max) }} title={`${d} ${h}:00 — ${v}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
// Maps EmailCategory enum name → folder slug or search query
|
// Maps EmailCategory enum name → folder slug or search query
|
||||||
const CAT_SLUG = {
|
const CAT_SLUG = {
|
||||||
Finance: '/app/folder/finance',
|
Finance: '/app/folder/finance',
|
||||||
@@ -112,64 +135,41 @@ const CAT_SLUG = {
|
|||||||
Unknown: '/app/folder/allmail',
|
Unknown: '/app/folder/allmail',
|
||||||
};
|
};
|
||||||
|
|
||||||
// "Emails by Category" — ranked horizontal bar list. Aggregates the
|
export function CategoryHeatmapWidget() {
|
||||||
// category×day-of-week cells into per-category totals; each bar links to
|
|
||||||
// that category's folder via CAT_SLUG.
|
|
||||||
export function CategoryBreakdownWidget() {
|
|
||||||
const [cells, setCells] = useState(null);
|
const [cells, setCells] = useState(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
|
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
|
||||||
|
|
||||||
if (!cells) {
|
if (!cells) return <div className="widget"><div className="muted">Loading…</div></div>;
|
||||||
return (
|
if (cells.length === 0) return <div className="widget"><h3>Category Heatmap</h3><div className="muted">No data yet — run a sync.</div></div>;
|
||||||
<div className="widget">
|
|
||||||
<h3>Emails by Category</h3>
|
|
||||||
<div className="cat-bars">
|
|
||||||
{Array.from({ length: 6 }, (_, i) => <Skeleton key={i} className="h-6 w-full" />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totals = {};
|
const categories = [...new Set(cells.map((c) => c.category))].sort();
|
||||||
cells.forEach((c) => { totals[c.category] = (totals[c.category] || 0) + c.count; });
|
const max = Math.max(1, ...cells.map((c) => c.count));
|
||||||
const ranked = Object.entries(totals)
|
const grid = {};
|
||||||
.map(([category, count]) => ({ category, count }))
|
cells.forEach((c) => { grid[`${c.category}-${c.dayOfWeek}`] = c.count; });
|
||||||
.sort((a, b) => b.count - a.count)
|
|
||||||
.slice(0, 8);
|
|
||||||
|
|
||||||
if (ranked.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="widget">
|
|
||||||
<h3>Emails by Category</h3>
|
|
||||||
<EmptyState icon={Inbox} title="No data yet — run a sync" description="Category totals appear once your inbox has been synced." />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = Math.max(1, ...ranked.map((r) => r.count));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="widget">
|
<div className="widget">
|
||||||
<h3>Emails by Category</h3>
|
<h3>Category Heatmap</h3>
|
||||||
<div className="cat-bars">
|
<div className="cat-heatmap">
|
||||||
{ranked.map(({ category, count }) => {
|
<div className="chm-row chm-head">
|
||||||
const dest = CAT_SLUG[category];
|
<span className="chm-label" />
|
||||||
|
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
|
||||||
|
</div>
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const dest = CAT_SLUG[cat];
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="chm-row" key={cat}>
|
||||||
type="button"
|
<span
|
||||||
className={`cat-bar${dest ? ' cat-bar--link' : ''}`}
|
className={`chm-label${dest ? ' chm-label--link' : ''}`}
|
||||||
key={category}
|
title={dest ? `View ${cat} emails` : cat}
|
||||||
disabled={!dest}
|
onClick={dest ? () => navigate(dest) : undefined}
|
||||||
title={dest ? `View ${category} emails` : category}
|
>{cat}</span>
|
||||||
onClick={dest ? () => navigate(dest) : undefined}
|
{DOW.map((_, dow) => {
|
||||||
>
|
const v = grid[`${cat}-${dow}`] || 0;
|
||||||
<span className="cat-bar-label">{category}</span>
|
return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]} — ${v}`}>{v || ''}</span>;
|
||||||
<span className="cat-bar-track">
|
})}
|
||||||
<span className="cat-bar-fill" style={{ width: `${(count / max) * 100}%` }} />
|
</div>
|
||||||
</span>
|
|
||||||
<span className="cat-bar-count">{count.toLocaleString()}</span>
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,10 +196,4 @@ export function StorageWidget({ bytes }) {
|
|||||||
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
|
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Empty() {
|
function Empty() { return <div className="widget"><div className="muted">No data yet — run a sync.</div></div>; }
|
||||||
return (
|
|
||||||
<div className="widget">
|
|
||||||
<EmptyState icon={Inbox} title="No data yet — run a sync" description="This widget populates after your inbox has been synced." />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
|
|||||||
import SyncSplash from '../components/SyncSplash.jsx';
|
import SyncSplash from '../components/SyncSplash.jsx';
|
||||||
import {
|
import {
|
||||||
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
|
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
|
||||||
CategoryBreakdownWidget, AttachmentsWidget, StorageWidget
|
CategoryHeatmapWidget, AttachmentsWidget, StorageWidget
|
||||||
} from '../components/widgets.jsx';
|
} from '../components/widgets.jsx';
|
||||||
import { Skeleton } from '../components/ui/skeleton.jsx';
|
|
||||||
|
|
||||||
// Default grid geometry; overridden by the user's saved layout.
|
// Default grid geometry; overridden by the user's saved layout.
|
||||||
const DEFAULT_LAYOUT = [
|
const DEFAULT_LAYOUT = [
|
||||||
@@ -18,23 +17,12 @@ const DEFAULT_LAYOUT = [
|
|||||||
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 },
|
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 },
|
||||||
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
|
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
|
||||||
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 },
|
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 },
|
||||||
{ i: 'category-breakdown', x: 0, y: 5, w: 6, h: 5 },
|
{ i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 },
|
||||||
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 },
|
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
|
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
|
||||||
|
|
||||||
function WidgetSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="widget">
|
|
||||||
<Skeleton className="mb-3 h-4 w-1/3" />
|
|
||||||
<Skeleton className="mb-2 h-3 w-full" />
|
|
||||||
<Skeleton className="mb-2 h-3 w-5/6" />
|
|
||||||
<Skeleton className="h-3 w-2/3" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [layout, setLayout] = useState(DEFAULT_LAYOUT);
|
const [layout, setLayout] = useState(DEFAULT_LAYOUT);
|
||||||
@@ -99,18 +87,15 @@ export default function Dashboard() {
|
|||||||
const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]);
|
const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]);
|
||||||
|
|
||||||
const render = (key) => {
|
const render = (key) => {
|
||||||
// category-breakdown self-fetches, so it renders regardless of dashboard load state.
|
|
||||||
if (key === 'category-breakdown') return <CategoryBreakdownWidget />;
|
|
||||||
// While the dashboard payload loads, show a skeleton placeholder per widget.
|
|
||||||
if (!data) return <WidgetSkeleton />;
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'inbox-health': return <HealthWidget health={data.health} />;
|
case 'inbox-health': return <HealthWidget health={data?.health} />;
|
||||||
case 'total-emails': return <StatCard label="Total Emails" value={(data.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />;
|
case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />;
|
||||||
case 'unread-emails': return <StatCard label="Unread" value={(data.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />;
|
case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />;
|
||||||
case 'storage': return <StorageWidget bytes={data.storageEstimateBytes} />;
|
case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
|
||||||
case 'top-senders': return <TopSendersWidget senders={data.topSenders} />;
|
case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
|
||||||
case 'volume': return <VolumeWidget volume={data.volumeOverTime} />;
|
case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
|
||||||
case 'attachments': return <AttachmentsWidget attachments={data.attachmentBreakdown} />;
|
case 'category-heatmap': return <CategoryHeatmapWidget />;
|
||||||
|
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-15
@@ -115,6 +115,8 @@ button:disabled { opacity: 0.5; cursor: default; }
|
|||||||
.widget--link:hover { border-color: var(--accent); }
|
.widget--link:hover { border-color: var(--accent); }
|
||||||
.mini-row--link { cursor: pointer; }
|
.mini-row--link { cursor: pointer; }
|
||||||
.mini-row--link:hover td { color: var(--accent); }
|
.mini-row--link:hover td { color: var(--accent); }
|
||||||
|
.chm-label--link { cursor: pointer; text-decoration: underline dotted; }
|
||||||
|
.chm-label--link:hover { color: var(--accent); }
|
||||||
.widget canvas { flex: 1; min-height: 0; }
|
.widget canvas { flex: 1; min-height: 0; }
|
||||||
|
|
||||||
.stat { align-items: flex-start; justify-content: center; }
|
.stat { align-items: flex-start; justify-content: center; }
|
||||||
@@ -133,21 +135,17 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
|
|||||||
.num { text-align: right; }
|
.num { text-align: right; }
|
||||||
.muted { color: var(--muted); font-size: 12px; }
|
.muted { color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
/* Emails by Category — ranked horizontal bars */
|
.heatmap { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.cat-bars { display: flex; flex-direction: column; gap: 6px; overflow: auto; }
|
.hm-row { display: flex; align-items: center; gap: 2px; }
|
||||||
.cat-bar {
|
.hm-day { width: 30px; font-size: 10px; color: var(--muted); }
|
||||||
display: grid; grid-template-columns: 92px 1fr 40px; gap: 8px; align-items: center;
|
.hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; }
|
||||||
width: 100%; padding: 3px 4px; margin: 0; border: none; background: transparent;
|
|
||||||
border-radius: 6px; text-align: left; font: inherit; color: inherit;
|
/* Category heatmap */
|
||||||
}
|
.cat-heatmap { display: flex; flex-direction: column; gap: 3px; overflow: auto; }
|
||||||
.cat-bar--link { cursor: pointer; }
|
.chm-row { display: grid; grid-template-columns: 92px repeat(7, 1fr); gap: 3px; align-items: stretch; }
|
||||||
.cat-bar--link:hover { background: rgba(255, 255, 255, 0.04); }
|
.chm-head .chm-col { font-size: 10px; color: var(--muted); text-align: center; }
|
||||||
.cat-bar:disabled { cursor: default; }
|
.chm-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.cat-bar-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.chm-cell { background: var(--accent); border-radius: 3px; min-height: 22px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; }
|
||||||
.cat-bar-track { height: 14px; background: rgba(255, 255, 255, 0.06); border-radius: 4px; overflow: hidden; }
|
|
||||||
.cat-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; min-width: 2px; }
|
|
||||||
.cat-bar-count { font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
|
||||||
.cat-bar--link:hover .cat-bar-label { color: var(--accent); }
|
|
||||||
|
|
||||||
/* react-grid-layout resize handle — make it clearly visible on the dark theme */
|
/* react-grid-layout resize handle — make it clearly visible on the dark theme */
|
||||||
.react-resizable-handle {
|
.react-resizable-handle {
|
||||||
|
|||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# InboxIntel pre-commit hook — fast checks only (keep it under a few seconds).
|
||||||
|
# Heavier build/test verification runs in pre-push. Bypass in an emergency with:
|
||||||
|
# git commit --no-verify
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
echo "[pre-commit] running fast checks..."
|
||||||
|
|
||||||
|
# 1) Block obvious secrets from being committed. Scans only staged, added lines.
|
||||||
|
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
|
||||||
|
if [ -n "$STAGED" ]; then
|
||||||
|
# Never allow a real .env (only *.example templates are tracked).
|
||||||
|
echo "$STAGED" | grep -E '(^|/)\.env($|\.)' | grep -v '\.example$' >/tmp/ii_env_hits 2>/dev/null || true
|
||||||
|
if [ -s /tmp/ii_env_hits ]; then
|
||||||
|
echo "[pre-commit] BLOCKED: attempting to commit an env/secret file:"
|
||||||
|
cat /tmp/ii_env_hits
|
||||||
|
echo " -> add it to .gitignore or commit .env.example instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Heuristic secret scan on added lines (private keys, obvious credential assigns).
|
||||||
|
if git diff --cached --unified=0 -- $STAGED \
|
||||||
|
| grep -E '^\+' \
|
||||||
|
| grep -Ei 'BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY|(client_secret|password|api[_-]?key|secret)\s*[:=]\s*["'"'"']?[A-Za-z0-9/_+=-]{16,}' \
|
||||||
|
| grep -Evi 'change-me|your-|example|placeholder|\$\{' >/tmp/ii_secret_hits 2>/dev/null; then
|
||||||
|
echo "[pre-commit] BLOCKED: possible hard-coded secret in staged changes:"
|
||||||
|
cat /tmp/ii_secret_hits
|
||||||
|
echo " -> use deploy/.env / user-secrets. Override with 'git commit --no-verify' if this is a false positive."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) Format .NET code (only if the tool + staged .cs files are present). Verify-only,
|
||||||
|
# non-mutating, so it never rewrites files out from under your staged diff.
|
||||||
|
if echo "$STAGED" | grep -q '\.cs$'; then
|
||||||
|
if command -v dotnet >/dev/null 2>&1 && dotnet format --help >/dev/null 2>&1; then
|
||||||
|
echo "[pre-commit] dotnet format --verify-no-changes"
|
||||||
|
dotnet format InboxIntel.sln --verify-no-changes --verbosity quiet \
|
||||||
|
|| { echo " -> run 'dotnet format InboxIntel.sln' and re-stage."; exit 1; }
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[pre-commit] OK"
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# InboxIntel pre-push hook — build + test gate. Mirrors what Gitea CI runs so you
|
||||||
|
# catch failures before they reach the server. Bypass with: git push --no-verify
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
echo "[pre-push] building solution (Release)..."
|
||||||
|
dotnet build InboxIntel.sln -c Release --nologo \
|
||||||
|
|| { echo "[pre-push] BLOCKED: backend build failed."; exit 1; }
|
||||||
|
|
||||||
|
echo "[pre-push] running tests..."
|
||||||
|
dotnet test InboxIntel.sln -c Release --no-build --nologo \
|
||||||
|
|| { echo "[pre-push] BLOCKED: tests failed."; exit 1; }
|
||||||
|
|
||||||
|
# Frontend build (only if the app is present and npm is installed).
|
||||||
|
if [ -f frontend/package.json ] && command -v npm >/dev/null 2>&1; then
|
||||||
|
echo "[pre-push] frontend build..."
|
||||||
|
( cd frontend && npm run build --silent ) \
|
||||||
|
|| { echo "[pre-push] BLOCKED: frontend build failed."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[pre-push] OK — safe to push."
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Point git at the version-controlled hooks in scripts/git-hooks.
|
||||||
|
.DESCRIPTION
|
||||||
|
Uses `git config core.hooksPath` so the hooks live in the repo (reviewable,
|
||||||
|
shared, updatable) instead of the un-tracked .git/hooks directory. Run once
|
||||||
|
per clone. Git for Windows ships the bash needed to execute the POSIX hooks.
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/install-hooks.ps1
|
||||||
|
#>
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Push-Location $root
|
||||||
|
try {
|
||||||
|
git config core.hooksPath scripts/git-hooks
|
||||||
|
# Best-effort exec bit (matters on Linux/WSL; harmless on Windows). Only applies
|
||||||
|
# once the hooks are tracked; ignored on a first run before they're committed.
|
||||||
|
foreach ($h in 'pre-commit','pre-push') {
|
||||||
|
try { git update-index --chmod=+x "scripts/git-hooks/$h" 2>$null } catch {}
|
||||||
|
}
|
||||||
|
Write-Host "Installed git hooks -> scripts/git-hooks (core.hooksPath set)." -ForegroundColor Green
|
||||||
|
Write-Host "Bypass in an emergency with --no-verify." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
finally { Pop-Location }
|
||||||
Reference in New Issue
Block a user