diff --git a/.gitignore b/.gitignore index 7abf907..84b25b6 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ dist-ssr # Tauri packages/desktop/src-tauri/target/ packages/desktop/src-tauri/gen/ + +# Drafts from scripts/pr-evidence.sh +PR_BODY.md diff --git a/.husky/pre-commit b/.husky/pre-commit index 28af0a6..5648194 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,14 @@ #!/bin/sh +# Pre-commit gates — must be fast (<2s typical). Anything slow goes in pre-push. +# +# Order: +# 1. Secret scan (must run first; blocks commit if a secret leaks in) +# 2. Biome format/lint on staged TS/JS/JSON/CSS files + +# 1. Secret scan over staged additions +./scripts/secret-scan.sh + +# 2. Biome on staged files (auto-fixes, re-stages) STAGED=$(git diff --cached --name-only --diff-filter=ACMR | grep -E "\.(ts|tsx|js|jsx|css|json)$" || true) [ -z "$STAGED" ] && exit 0 pnpm exec biome check --write --staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..e96f139 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,8 @@ +#!/bin/sh +# Pre-push gate — runs the full quality check before the branch leaves the +# laptop. Mirrors the `check` job in .github/workflows/ci.yml. +# +# Bypass for genuine emergencies with: git push --no-verify + +echo "→ Running pre-push checks (lint + typecheck + test)…" +pnpm check diff --git a/package.json b/package.json index 8dd2670..2d9ed37 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "check": "turbo run lint typecheck test", "ci:web": "turbo run lint typecheck test build --filter=@openconcho/web", "ci:desktop": "turbo run cargo-check --filter=@openconcho/desktop", + "pr:evidence": "./scripts/pr-evidence.sh", "prepare": "husky" }, "devDependencies": { diff --git a/scripts/pr-evidence.sh b/scripts/pr-evidence.sh new file mode 100755 index 0000000..4d28e75 --- /dev/null +++ b/scripts/pr-evidence.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Draft a PR body for the current branch based on the diff vs origin/main. +# +# Writes to PR_BODY.md in the repo root (gitignored — see end of script). +# Pre-fills the structure required by .github/pull_request_template.md +# and flags whether screenshots are required based on touched paths. +# +# Usage: +# ./scripts/pr-evidence.sh # writes ./PR_BODY.md +# ./scripts/pr-evidence.sh > /tmp/body.md # write to stdout + +set -euo pipefail + +OUTPUT="${1:-PR_BODY.md}" + +# Find the base branch (default origin/main) the current branch diverged from. +BASE_REF="${BASE_REF:-origin/main}" +git fetch origin main --quiet 2>/dev/null || true + +MERGE_BASE=$(git merge-base HEAD "$BASE_REF" 2>/dev/null || echo "$BASE_REF") +CHANGED=$(git diff --name-only "$MERGE_BASE"...HEAD) +ADDED=$(git diff --name-status --diff-filter=A "$MERGE_BASE"...HEAD | awk '{print $2}') +MODIFIED=$(git diff --name-status --diff-filter=M "$MERGE_BASE"...HEAD | awk '{print $2}') +DELETED=$(git diff --name-status --diff-filter=D "$MERGE_BASE"...HEAD | awk '{print $2}') + +# Heuristic: any touched path under packages/web/src/{components,routes} or +# packages/desktop counts as a UI change and requires screenshots. +UI_CHANGED=0 +if echo "$CHANGED" | grep -qE '^(packages/web/src/(components|routes)|packages/desktop)/'; then + UI_CHANGED=1 +fi + +# Commits since base — useful for the "What" section. +COMMITS=$(git log --pretty=format:'- %s' "$MERGE_BASE"..HEAD) + +# Tests touched? +TESTS_TOUCHED=$(echo "$CHANGED" | grep -E '(\.test\.|/test/|/e2e/)' || true) + +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +draft() { + cat < + +## Why + + + +## What + +$(if [ -n "$COMMITS" ]; then printf 'Commits on this branch:\n%s\n' "$COMMITS"; else echo ''; fi) + +$(if [ -n "$ADDED" ]; then printf '\n**Added:**\n'; printf '%s\n' "$ADDED" | sed 's/^/- /'; fi) +$(if [ -n "$MODIFIED" ]; then printf '\n**Modified:**\n'; printf '%s\n' "$MODIFIED" | sed 's/^/- /'; fi) +$(if [ -n "$DELETED" ]; then printf '\n**Deleted:**\n'; printf '%s\n' "$DELETED" | sed 's/^/- /'; fi) + +## Screenshots + +EOF + + if [ $UI_CHANGED -eq 1 ]; then + cat </\` and reference here: + +\`\`\`markdown +![Description](https://raw.githubusercontent.com/BenSheridanEdwards/openconcho/${BRANCH}/docs/screenshots//01-.png) +\`\`\` + +See \`.claude/rules/workflows.md\` → "Open a PR" for capture + commit guidance. + +EOF + else + cat < + +EOF + fi + + cat < +$(if echo "$CHANGED" | grep -qE '^packages/desktop/'; then echo '- [ ] \`pnpm --filter @openconcho/desktop cargo-check\` passes'; fi) +- [x] Worked in a git worktree (current branch: \`${BRANCH}\`) + +## Out-of-scope + + + +## Notes + + +EOF +} + +if [ "$OUTPUT" = "-" ] || [ -t 1 ]; then + # When piped or first arg is "-", write to stdout. + if [ "${1:-}" = "-" ]; then + draft + exit 0 + fi +fi + +draft > "$OUTPUT" + +echo "✓ Drafted PR body → ${OUTPUT}" +echo " Open it, fill in Why / Manual verification / Out-of-scope / Notes, then use as the PR body." diff --git a/scripts/secret-scan.sh b/scripts/secret-scan.sh new file mode 100755 index 0000000..69f4517 --- /dev/null +++ b/scripts/secret-scan.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Secret scan for staged files. +# +# Pre-commit hook calls this against staged additions. Fast (no external +# tool; just regex over the staged diff). Designed to catch the common +# accidents — API keys committed alongside code — not to replace a full +# secret-scanning service. +# +# Exits non-zero with a clear message if a likely secret is found. + +set -euo pipefail + +# Only scan added/modified content (the `+` lines in the staged diff). +# This avoids false positives from existing committed strings. +STAGED_DIFF=$(git diff --cached --diff-filter=ACMR --unified=0 -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.json' '*.yml' '*.yaml' '*.toml' '*.env*' '*.sh' '*.md' 2>/dev/null || true) + +if [ -z "$STAGED_DIFF" ]; then + exit 0 +fi + +# Only look at added lines (starting with `+`, excluding diff headers `+++`). +ADDED=$(printf '%s\n' "$STAGED_DIFF" | grep -E '^\+[^+]' || true) + +if [ -z "$ADDED" ]; then + exit 0 +fi + +FOUND=0 +FINDINGS="" + +check_pattern() { + local name="$1" + local pattern="$2" + # Use `-e` to safely pass patterns that begin with `-` (e.g. PEM headers). + if printf '%s\n' "$ADDED" | grep -qE -e "$pattern"; then + FOUND=1 + FINDINGS="${FINDINGS} - ${name}\n" + fi +} + +check_pattern "AWS access key" 'AKIA[0-9A-Z]{16}' +check_pattern "AWS secret key (high-entropy)" 'aws_secret_access_key[[:space:]]*[:=][[:space:]]*[A-Za-z0-9/+=]{40}' +check_pattern "Anthropic API key" 'sk-ant-[a-zA-Z0-9_-]{32,}' +check_pattern "OpenAI API key" 'sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}' +check_pattern "OpenAI project key (newer)" 'sk-proj-[a-zA-Z0-9_-]{40,}' +check_pattern "GitHub personal access token" 'gh[psoru]_[A-Za-z0-9_]{36,}' +check_pattern "GitHub fine-grained PAT" 'github_pat_[A-Za-z0-9_]{82,}' +check_pattern "Slack token" 'xox[abprs]-[A-Za-z0-9-]{10,}' +check_pattern "Google API key" 'AIza[0-9A-Za-z_-]{35}' +check_pattern "Stripe live key" 'sk_live_[A-Za-z0-9]{24,}' +check_pattern "Honcho-style JWT (likely)" 'eyJ[A-Za-z0-9_-]{20,}\.eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}' +check_pattern "RSA/EC/DSA/OpenSSH private key block" '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----' +check_pattern "Generic hardcoded password" '(password|passwd|pwd)[[:space:]]*[:=][[:space:]]*["'\'']\w{8,}["'\'']' + +if [ $FOUND -eq 1 ]; then + printf '\n\033[31m✗ Secret scan: potential secrets in staged changes\033[0m\n' >&2 + printf '%b' "$FINDINGS" >&2 + printf '\n' >&2 + printf 'If this is a false positive, bypass with: \033[33mgit commit --no-verify\033[0m\n' >&2 + printf 'Otherwise: remove the secret, rotate the credential, and re-stage.\n\n' >&2 + exit 1 +fi + +exit 0