CKB #352
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # ██████╗██╗ ██╗██████╗ █████╗ ███╗ ██╗ █████╗ ██╗ ██╗ ██╗███████╗██╗███████╗ | |
| # ██╔════╝██║ ██╔╝██╔══██╗ ██╔══██╗████╗ ██║██╔══██╗██║ ╚██╗ ██╔╝██╔════╝██║██╔════╝ | |
| # ██║ █████╔╝ ██████╔╝ ███████║██╔██╗ ██║███████║██║ ╚████╔╝ ███████╗██║███████╗ | |
| # ██║ ██╔═██╗ ██╔══██╗ ██╔══██║██║╚██╗██║██╔══██║██║ ╚██╔╝ ╚════██║██║╚════██║ | |
| # ╚██████╗██║ ██╗██████╔╝ ██║ ██║██║ ╚████║██║ ██║███████╗██║ ███████║██║███████║ | |
| # ╚═════╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚══════╝╚═╝╚══════╝ | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Code Intelligence for Pull Requests — Full Feature Showcase | |
| # | |
| # Features: | |
| # • Risk Scoring — Quantified risk based on hotspots, modules, history | |
| # • Blast Radius — Know exactly what breaks before merging | |
| # • Complexity Gates — Block merges exceeding thresholds | |
| # • Coupling Analysis — Warn when coupled files change independently | |
| # • Contract Detection — Flag API/proto/GraphQL boundary changes | |
| # • Dead Code — Identify unused code from telemetry | |
| # • Ownership Drift — CODEOWNERS vs actual contributors | |
| # • Suggested Reviewers — Based on real code ownership | |
| # • Eval Suite — Regression testing for search quality | |
| # • Language Quality — Per-language indexer quality metrics | |
| # | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| name: CKB | |
| on: | |
| pull_request: | |
| branches: [develop, main, 'feature/**'] | |
| schedule: | |
| - cron: '0 3 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| force: | |
| description: 'Force full reindex' | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: ckb-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| env: | |
| # ┌─────────────────────────────────────────────────────────────────────────┐ | |
| # │ THRESHOLDS │ | |
| # └─────────────────────────────────────────────────────────────────────────┘ | |
| COMPLEXITY_CYCLOMATIC: 15 | |
| COMPLEXITY_COGNITIVE: 20 | |
| COUPLING_MIN: 0.7 | |
| DOC_COVERAGE_MIN: 70 | |
| RISK_SCORE_MIN: 60 | |
| EVAL_PASS_RATE: 90 | |
| # Set to 'true' to block PRs with complexity violations | |
| COMPLEXITY_GATE_ENABLED: 'false' | |
| jobs: | |
| # ╔═════════════════════════════════════════════════════════════════════════╗ | |
| # ║ PR ANALYSIS ║ | |
| # ╚═════════════════════════════════════════════════════════════════════════╝ | |
| analyze: | |
| name: Analyze | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| outputs: | |
| risk: ${{ steps.summary.outputs.risk }} | |
| score: ${{ steps.summary.outputs.score }} | |
| steps: | |
| # ─────────────────────────────────────────────────────────────────────── | |
| # Setup | |
| # ─────────────────────────────────────────────────────────────────────── | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: 'go.mod' | |
| cache: true | |
| - name: Build | |
| run: go build -ldflags="-s -w" -o ckb ./cmd/ckb | |
| - name: Install SCIP indexer | |
| run: go install github.com/sourcegraph/scip-go/cmd/scip-go@latest | |
| # ─────────────────────────────────────────────────────────────────────── | |
| # Cache & Index | |
| # ─────────────────────────────────────────────────────────────────────── | |
| - name: Cache | |
| id: cache | |
| uses: actions/cache@v5 | |
| with: | |
| path: .ckb/ | |
| key: ckb-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ github.base_ref }} | |
| restore-keys: ckb-${{ runner.os }}- | |
| - name: Index | |
| id: index | |
| run: | | |
| START=$(date +%s) | |
| ./ckb init | |
| if ! ./ckb index 2>&1 | tee /tmp/index.log; then | |
| if grep -q "Indexer not found" /tmp/index.log; then | |
| echo "::error::SCIP indexer not installed" | |
| echo "" | |
| echo "╔═══════════════════════════════════════════════════════════════════════════════╗" | |
| echo "║ INDEXER NOT FOUND ║" | |
| echo "╠═══════════════════════════════════════════════════════════════════════════════╣" | |
| echo "║ Go: go install github.com/sourcegraph/scip-go/cmd/scip-go@latest ║" | |
| echo "║ TypeScript: npm i -g @sourcegraph/scip-typescript ║" | |
| echo "║ Python: pip install scip-python ║" | |
| echo "║ Rust: cargo install scip ║" | |
| echo "╚═══════════════════════════════════════════════════════════════════════════════╝" | |
| exit 1 | |
| fi | |
| echo "::warning::Indexing failed, continuing with fallback" | |
| echo "mode=fallback" >> $GITHUB_OUTPUT | |
| else | |
| echo "mode=indexed" >> $GITHUB_OUTPUT | |
| fi | |
| echo "duration=$(($(date +%s)-START))s" >> $GITHUB_OUTPUT | |
| # ─────────────────────────────────────────────────────────────────────── | |
| # Core Analysis | |
| # ─────────────────────────────────────────────────────────────────────── | |
| - name: PR Summary | |
| id: summary | |
| run: | | |
| ./ckb pr-summary --base=origin/${{ github.base_ref }} --format=json > analysis.json 2>/dev/null || echo '{}' > analysis.json | |
| echo "risk=$(jq -r '.riskAssessment.level // "unknown"' analysis.json)" >> $GITHUB_OUTPUT | |
| echo "score=$(jq -r '.riskAssessment.score // 0' analysis.json)" >> $GITHUB_OUTPUT | |
| # ─────────────────────────────────────────────────────────────────────── | |
| # Change Impact Analysis (v7.5) — Dogfooding our own tool | |
| # ─────────────────────────────────────────────────────────────────────── | |
| - name: Impact Analysis | |
| id: impact | |
| run: | | |
| # Generate both JSON (for metrics) and Markdown (for comment) | |
| ./ckb impact diff --base=origin/${{ github.base_ref }} --depth=2 --format=json > impact.json 2>/dev/null || echo '{"summary":{}}' > impact.json | |
| ./ckb impact diff --base=origin/${{ github.base_ref }} --depth=2 --format=markdown > impact.md 2>/dev/null || echo "## ⚪ Change Impact Analysis\n\nNo impact data available." > impact.md | |
| # Extract key metrics | |
| echo "symbols_changed=$(jq '.summary.symbolsChanged // 0' impact.json)" >> $GITHUB_OUTPUT | |
| echo "directly_affected=$(jq '.summary.directlyAffected // 0' impact.json)" >> $GITHUB_OUTPUT | |
| echo "transitively_affected=$(jq '.summary.transitivelyAffected // 0' impact.json)" >> $GITHUB_OUTPUT | |
| echo "estimated_risk=$(jq -r '.summary.estimatedRisk // "unknown"' impact.json)" >> $GITHUB_OUTPUT | |
| echo "module_count=$(jq '.blastRadius.moduleCount // 0' impact.json)" >> $GITHUB_OUTPUT | |
| echo "file_count=$(jq '.blastRadius.fileCount // 0' impact.json)" >> $GITHUB_OUTPUT | |
| # Log summary for debugging | |
| echo "::group::Impact Analysis Summary" | |
| jq '.summary' impact.json | |
| echo "::endgroup::" | |
| # Warn on high-risk changes | |
| RISK=$(jq -r '.summary.estimatedRisk // "unknown"' impact.json) | |
| if [ "$RISK" = "critical" ]; then | |
| echo "::error::CRITICAL RISK: This change has significant downstream impact" | |
| elif [ "$RISK" = "high" ]; then | |
| echo "::warning::HIGH RISK: This change affects many downstream symbols" | |
| fi | |
| - name: Post Impact Comment | |
| uses: marocchino/sticky-pull-request-comment@v2 | |
| with: | |
| header: ckb-impact | |
| path: impact.md | |
| - name: Complexity | |
| id: complexity | |
| run: | | |
| echo '[]' > complexity.json | |
| VIOLATIONS=0 | |
| for f in $(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(go|ts|js|py)$' | head -20); do | |
| [ -f "$f" ] || continue | |
| r=$(./ckb complexity "$f" --format=json 2>/dev/null || echo '{}') | |
| cy=$(echo "$r" | jq '.summary.maxCyclomatic // 0') | |
| cg=$(echo "$r" | jq '.summary.maxCognitive // 0') | |
| if [ "$cy" -gt ${{ env.COMPLEXITY_CYCLOMATIC }} ]; then | |
| echo "::warning file=$f::Cyclomatic complexity $cy exceeds threshold ${{ env.COMPLEXITY_CYCLOMATIC }}" | |
| VIOLATIONS=$((VIOLATIONS+1)) | |
| fi | |
| if [ "$cg" -gt ${{ env.COMPLEXITY_COGNITIVE }} ]; then | |
| echo "::warning file=$f::Cognitive complexity $cg exceeds threshold ${{ env.COMPLEXITY_COGNITIVE }}" | |
| VIOLATIONS=$((VIOLATIONS+1)) | |
| fi | |
| jq -n --arg f "$f" --argjson cy "$cy" --argjson cg "$cg" \ | |
| '{file:$f,cyclomatic:$cy,cognitive:$cg}' >> complexity.json.tmp | |
| done | |
| [ -f complexity.json.tmp ] && jq -s '.' complexity.json.tmp > complexity.json || echo '[]' > complexity.json | |
| rm -f complexity.json.tmp | |
| echo "violations=$VIOLATIONS" >> $GITHUB_OUTPUT | |
| - name: Coupling | |
| id: coupling | |
| run: | | |
| # Get list of changed files for comparison | |
| changed_files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(go|ts|js|py)$' || true) | |
| echo '[]' > missing_coupled.json | |
| for f in $(echo "$changed_files" | head -10); do | |
| [ -f "$f" ] || continue | |
| result=$(./ckb coupling "$f" --min-correlation=${{ env.COUPLING_MIN }} --format=json 2>/dev/null || echo '{}') | |
| # Extract correlations and check if coupled files are in the PR | |
| echo "$result" | jq -r --arg changed "$changed_files" --arg source "$f" ' | |
| .correlations // [] | .[] | | |
| select(.correlation >= ${{ env.COUPLING_MIN }}) | | |
| select(($changed | split("\n") | map(select(. != "")) | index(.file)) == null) | | |
| {file: .file, coupledTo: $source, correlation: .correlation} | |
| ' 2>/dev/null >> missing_coupled.json.tmp || true | |
| done | |
| # Deduplicate and format final output | |
| if [ -f missing_coupled.json.tmp ]; then | |
| jq -s 'unique_by(.file) | {missingCoupled: .}' missing_coupled.json.tmp > coupling.json 2>/dev/null || echo '{"missingCoupled":[]}' > coupling.json | |
| rm -f missing_coupled.json.tmp | |
| else | |
| echo '{"missingCoupled":[]}' > coupling.json | |
| fi | |
| echo "missing=$(jq '.missingCoupled | length' coupling.json)" >> $GITHUB_OUTPUT | |
| - name: Contracts | |
| id: contracts | |
| run: | | |
| echo '{"files":[],"breaking":[]}' > contracts.json | |
| contracts=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(proto|graphql|gql|openapi\.ya?ml)$' || true) | |
| if [ -n "$contracts" ]; then | |
| # List contract files - breaking change detection not available in CLI | |
| echo "$contracts" | jq -R -s 'split("\n") | map(select(length > 0)) | {files: ., breaking: []}' > contracts.json | |
| echo "::warning::Contract files changed: $(echo "$contracts" | tr '\n' ' ')" | |
| fi | |
| echo "count=$(jq '.files | length' contracts.json)" >> $GITHUB_OUTPUT | |
| echo "breaking=$(jq '.breaking | length' contracts.json)" >> $GITHUB_OUTPUT | |
| - name: Blast Radius | |
| id: blast | |
| run: | | |
| # Derive blast radius from pr-summary changed files | |
| # Note: v7.6+ provides per-symbol blast radius via `ckb impact <symbol-id>` | |
| # TODO: Add --include-blast-radius to pr-summary for aggregate PR-level data | |
| if [ -f analysis.json ]; then | |
| jq '{ | |
| affectedSymbols: (.changedFiles // []) | map(.symbols // []) | flatten, | |
| affectedTests: (.changedFiles // []) | map(select(.path | test("_test\\.go$|test_|\\.test\\."))) | map(.path), | |
| affectedConsumers: [] | |
| }' analysis.json > blast.json 2>/dev/null || echo '{"affectedSymbols":[],"affectedTests":[],"affectedConsumers":[]}' > blast.json | |
| else | |
| echo '{"affectedSymbols":[],"affectedTests":[],"affectedConsumers":[]}' > blast.json | |
| fi | |
| echo "symbols=$(jq '.affectedSymbols | length' blast.json)" >> $GITHUB_OUTPUT | |
| echo "tests=$(jq '.affectedTests | length' blast.json)" >> $GITHUB_OUTPUT | |
| echo "consumers=$(jq '.affectedConsumers | length' blast.json)" >> $GITHUB_OUTPUT | |
| - name: Audit | |
| id: audit | |
| run: | | |
| ./ckb audit --min-score=${{ env.RISK_SCORE_MIN }} --limit=10 --quick-wins --format=json > audit.json 2>/dev/null || echo '{"items":[],"quickWins":[],"summary":{}}' > audit.json | |
| echo "critical=$(jq '.summary.critical // 0' audit.json)" >> $GITHUB_OUTPUT | |
| echo "high=$(jq '.summary.high // 0' audit.json)" >> $GITHUB_OUTPUT | |
| - name: Dead Code | |
| run: | | |
| ./ckb dead-code --min-confidence=0.9 --limit=10 --format=json > deadcode.json 2>/dev/null || echo '{"candidates":[]}' > deadcode.json | |
| - name: Telemetry Dead Code | |
| id: telemetry_dead | |
| run: | | |
| # Check if telemetry is configured (optional feature) | |
| if ./ckb telemetry status 2>/dev/null | grep -q "connected"; then | |
| ./ckb dead-code --source=telemetry --days=30 --limit=10 --format=json > telemetry-deadcode.json 2>/dev/null || echo '{"candidates":[],"source":"telemetry"}' > telemetry-deadcode.json | |
| echo "enabled=true" >> $GITHUB_OUTPUT | |
| echo "count=$(jq '.candidates | length // 0' telemetry-deadcode.json)" >> $GITHUB_OUTPUT | |
| else | |
| echo '{"candidates":[],"source":"telemetry","coverage":{}}' > telemetry-deadcode.json | |
| echo "enabled=false" >> $GITHUB_OUTPUT | |
| echo "count=0" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Docs | |
| id: docs | |
| run: | | |
| ./ckb docs index 2>/dev/null || true | |
| ./ckb docs coverage --format=json > docs-coverage.json 2>/dev/null || echo '{"coverage":0}' > docs-coverage.json | |
| ./ckb docs stale --all --format=json > docs-stale.json 2>/dev/null || echo '{"totalStale":0,"reports":[]}' > docs-stale.json | |
| echo "coverage=$(jq '.coveragePercent // 0' docs-coverage.json)" >> $GITHUB_OUTPUT | |
| echo "stale=$(jq '.totalStale // 0' docs-stale.json)" >> $GITHUB_OUTPUT | |
| - name: Drift | |
| run: | | |
| ./ckb ownership drift --threshold=0.3 --limit=10 --format=json > drift.json 2>/dev/null || echo '{"driftedFiles":[]}' > drift.json | |
| - name: Affected Tests | |
| id: affected_tests | |
| run: | | |
| RANGE="origin/${{ github.base_ref }}..HEAD" | |
| ./ckb affected-tests --range="$RANGE" --format=json > affected-tests.json 2>/dev/null || echo '{"tests":[],"strategy":"none"}' > affected-tests.json | |
| echo "count=$(jq '.tests | length' affected-tests.json)" >> $GITHUB_OUTPUT | |
| echo "strategy=$(jq -r '.strategy // "none"' affected-tests.json)" >> $GITHUB_OUTPUT | |
| - name: Languages | |
| id: languages | |
| run: | | |
| # Use doctor to check actual indexer status | |
| ./ckb doctor --format=json > doctor.json 2>/dev/null || echo '{"languages":[]}' > doctor.json | |
| # Derive quality from doctor output | |
| jq '{ | |
| languages: [(.languages // [])[] | { | |
| name, | |
| quality: (if .status == "ready" then 1 else if .status == "partial" then 0.5 else 0 end end), | |
| tier: (.tier // "unknown"), | |
| status: (.status // "unknown"), | |
| issues: (.issues // []) | |
| }], | |
| overallQuality: ( | |
| [(.languages // [])[] | if .status == "ready" then 1 else 0 end] | | |
| if length > 0 then add / length else 1 end | |
| ) | |
| }' doctor.json > languages.json 2>/dev/null || echo '{"languages":[],"overallQuality":1}' > languages.json | |
| echo "quality=$(jq '.overallQuality // 1' languages.json)" >> $GITHUB_OUTPUT | |
| echo "lowQuality=$(jq '[.languages[] | select(.quality < 0.7)] | length' languages.json)" >> $GITHUB_OUTPUT | |
| - name: Eval | |
| id: eval | |
| run: | | |
| if [ -d ".ckb/fixtures" ]; then | |
| ./ckb eval --fixtures=.ckb/fixtures --format=json > eval.json 2>/dev/null || echo '{"passedTests":0,"totalTests":0,"results":[]}' > eval.json | |
| echo "skipped=false" >> $GITHUB_OUTPUT | |
| else | |
| echo '{"passedTests":0,"totalTests":0,"results":[],"skipped":true}' > eval.json | |
| echo "skipped=true" >> $GITHUB_OUTPUT | |
| fi | |
| echo "passed=$(jq '.passedTests // 0' eval.json)" >> $GITHUB_OUTPUT | |
| echo "total=$(jq '.totalTests // 0' eval.json)" >> $GITHUB_OUTPUT | |
| # ─────────────────────────────────────────────────────────────────────── | |
| # Quality Gates | |
| # ─────────────────────────────────────────────────────────────────────── | |
| - name: Complexity Gate | |
| if: env.COMPLEXITY_GATE_ENABLED == 'true' && steps.complexity.outputs.violations > 0 | |
| run: | | |
| echo "::error::❌ Complexity gate failed: ${{ steps.complexity.outputs.violations }} violations" | |
| echo "" | |
| echo "Files exceeding thresholds:" | |
| jq -r '.[] | select(.cyclomatic > ${{ env.COMPLEXITY_CYCLOMATIC }} or .cognitive > ${{ env.COMPLEXITY_COGNITIVE }}) | " \(.file): cyclomatic=\(.cyclomatic), cognitive=\(.cognitive)"' complexity.json | |
| exit 1 | |
| # ─────────────────────────────────────────────────────────────────────── | |
| # Report | |
| # ─────────────────────────────────────────────────────────────────────── | |
| - name: Comment | |
| if: always() | |
| uses: actions/github-script@v8 | |
| env: | |
| CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} | |
| INDEX_MODE: ${{ steps.index.outputs.mode }} | |
| INDEX_TIME: ${{ steps.index.outputs.duration }} | |
| COMPLEXITY_CYCLOMATIC: ${{ env.COMPLEXITY_CYCLOMATIC }} | |
| COMPLEXITY_COGNITIVE: ${{ env.COMPLEXITY_COGNITIVE }} | |
| DOC_COVERAGE_MIN: ${{ env.DOC_COVERAGE_MIN }} | |
| EVAL_PASS_RATE: ${{ env.EVAL_PASS_RATE }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f)); } catch { return d; } }; | |
| // Thresholds | |
| const TH_CY = parseInt(process.env.COMPLEXITY_CYCLOMATIC || '15'); | |
| const TH_CG = parseInt(process.env.COMPLEXITY_COGNITIVE || '20'); | |
| const TH_DOC = parseInt(process.env.DOC_COVERAGE_MIN || '70'); | |
| const TH_EVAL = parseInt(process.env.EVAL_PASS_RATE || '90'); | |
| // Load all analysis data | |
| const pr = read('analysis.json', {}); | |
| const impact = read('impact.json', { summary: {}, changedSymbols: [], affectedSymbols: [], blastRadius: {}, recommendations: [] }); | |
| const complexity = read('complexity.json', []); | |
| const coupling = read('coupling.json', { missingCoupled: [] }); | |
| const contracts = read('contracts.json', { files: [], breaking: [] }); | |
| const blast = read('blast.json', { affectedSymbols: [], affectedTests: [], affectedConsumers: [] }); | |
| const audit = read('audit.json', { items: [], quickWins: [], summary: {} }); | |
| const deadcode = read('deadcode.json', { candidates: [] }); | |
| const telemetryDead = read('telemetry-deadcode.json', { candidates: [], source: 'telemetry' }); | |
| const docsCov = read('docs-coverage.json', { coverage: 0 }); | |
| const docsStale = read('docs-stale.json', { totalStale: 0, reports: [] }); | |
| const drift = read('drift.json', []); | |
| const languages = read('languages.json', { languages: [], overallQuality: 1 }); | |
| const evalResults = read('eval.json', { passed: 0, total: 0, results: [], skipped: true }); | |
| const affectedTests = read('affected-tests.json', { tests: [], strategy: 'none' }); | |
| // Extract data | |
| const s = pr.summary || {}; | |
| const risk = pr.riskAssessment || {}; | |
| const reviewers = pr.suggestedReviewers || []; | |
| const modules = pr.modulesAffected || []; | |
| const hotspots = (pr.changedFiles || []).filter(f => f.isHotspot); | |
| const breakingChanges = Array.isArray(contracts.breaking) ? contracts.breaking : []; | |
| const blastSymbols = blast.affectedSymbols || []; | |
| const blastTests = blast.affectedTests || []; | |
| const blastConsumers = blast.affectedConsumers || []; | |
| const lowQualityLangs = (languages.languages || []).filter(l => (l.quality || 1) < 0.7); | |
| // Impact analysis data | |
| const impactSummary = impact.summary || {}; | |
| const changedSymbols = impact.changedSymbols || []; | |
| const affectedSymbols = impact.affectedSymbols || []; | |
| const impactBlast = impact.blastRadius || {}; | |
| const impactRecs = impact.recommendations || []; | |
| const impactRisk = impactSummary.estimatedRisk || 'unknown'; | |
| // Computed | |
| const complexViolations = complexity.filter(c => c.cyclomatic > TH_CY || c.cognitive > TH_CG); | |
| const criticalItems = (audit.items || []).filter(i => i.riskLevel === 'critical'); | |
| const highItems = (audit.items || []).filter(i => i.riskLevel === 'high'); | |
| const riskyModules = modules.filter(m => m.riskLevel === 'high' || m.riskLevel === 'medium'); | |
| // Helpers | |
| const pct = v => Math.round((v || 0) * 100); | |
| const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; | |
| const sha = context.payload.pull_request?.head?.sha || context.sha; | |
| const fileLink = (path, line) => `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${sha}/${path}${line ? '#L' + line : ''}`; | |
| const linkedFile = (path, line) => `[\`${path}${line ? ':' + line : ''}\`](${fileLink(path, line)})`; | |
| // Risk styling | |
| const riskStyle = { | |
| high: { color: 'e74c3c', label: 'HIGH' }, | |
| medium: { color: 'f39c12', label: 'MEDIUM' }, | |
| low: { color: '27ae60', label: 'LOW' } | |
| }[risk.level] || { color: '95a5a6', label: 'UNKNOWN' }; | |
| // ═══════════════════════════════════════════════════════════════════ | |
| // Build Comment | |
| // ═══════════════════════════════════════════════════════════════════ | |
| let c = []; | |
| // Header | |
| c.push('<!-- ckb -->'); | |
| c.push(''); | |
| c.push('## CKB Analysis'); | |
| c.push(''); | |
| // Badges (cap risk score at 100%) | |
| const riskPct = Math.min(pct(risk.score), 100); | |
| c.push(`[](${runUrl}) ` + | |
| ` ` + | |
| ` ` + | |
| ` ` + | |
| ``); | |
| c.push(''); | |
| // Quick Stats Line | |
| const stats = []; | |
| if (impactSummary.symbolsChanged) stats.push(`🎯 ${impactSummary.symbolsChanged} changed → ${impactSummary.transitivelyAffected || 0} affected`); | |
| if (hotspots.length) stats.push(`🔥 ${hotspots.length} hotspot${hotspots.length > 1 ? 's' : ''}`); | |
| if (criticalItems.length + highItems.length) stats.push(`⚠️ ${criticalItems.length + highItems.length} risk`); | |
| if (complexViolations.length) stats.push(`📊 ${complexViolations.length} complex`); | |
| if (coupling.missingCoupled?.length) stats.push(`🔗 ${coupling.missingCoupled.length} coupled`); | |
| if (breakingChanges.length) stats.push(`💥 ${breakingChanges.length} breaking`); | |
| if (blastSymbols.length + blastTests.length) stats.push(`💣 ${blastSymbols.length + blastTests.length} blast`); | |
| if (contracts.files?.length) stats.push(`📜 ${contracts.files.length} contract${contracts.files.length > 1 ? 's' : ''}`); | |
| if (docsStale.totalStale) stats.push(`📚 ${docsStale.totalStale} stale`); | |
| if (deadcode.candidates?.length) stats.push(`💀 ${deadcode.candidates.length} dead`); | |
| if (telemetryDead.candidates?.length) stats.push(`📡 ${telemetryDead.candidates.length} telemetry`); | |
| if (affectedTests.tests?.length) stats.push(`🧪 ${affectedTests.tests.length} tests`); | |
| if (lowQualityLangs.length) stats.push(`🌐 ${lowQualityLangs.length} lang`); | |
| if (stats.length) { | |
| c.push(`> ${stats.join(' · ')}`); | |
| c.push(''); | |
| } | |
| // Risk Factors | |
| if (risk.factors?.length) { | |
| c.push('**Risk factors:** ' + risk.factors.slice(0, 3).join(' • ')); | |
| c.push(''); | |
| } | |
| // Suggested Reviewers | |
| if (reviewers.length) { | |
| const list = reviewers.slice(0, 3).map(r => `**${r.owner.replace(/^@?/, '@')}** (${pct(r.coverage)}%)`).join(', '); | |
| c.push(`👥 Suggested: ${list}`); | |
| c.push(''); | |
| } | |
| // Metrics Table | |
| const impactIcon = { critical: '🔴', high: '🟠', medium: '🟡', low: '🟢' }[impactRisk] || '⚪'; | |
| c.push('| Metric | Value | |'); | |
| c.push('|:-------|------:|:-:|'); | |
| c.push(`| Impact Analysis | ${impactSummary.symbolsChanged || 0} symbols → ${impactSummary.transitivelyAffected || 0} affected | ${impactIcon} |`); | |
| c.push(`| Doc Coverage | ${docsCov.coveragePercent || docsCov.coverage || 0}% | ${(docsCov.coveragePercent || docsCov.coverage || 0) >= TH_DOC ? '✅' : '⚠️'} |`); | |
| c.push(`| Complexity | ${complexViolations.length} violations | ${complexViolations.length === 0 ? '✅' : '⚠️'} |`); | |
| c.push(`| Coupling | ${coupling.missingCoupled?.length || 0} gaps | ${!coupling.missingCoupled?.length ? '✅' : '⚠️'} |`); | |
| c.push(`| Blast Radius | ${impactBlast.moduleCount || 0} modules, ${impactBlast.fileCount || 0} files | ${(impactBlast.moduleCount || 0) <= 2 ? '✅' : '⚠️'} |`); | |
| c.push(`| Index | [${process.env.INDEX_MODE || 'unknown'} (${process.env.INDEX_TIME || '?'})](${runUrl}) | ${process.env.CACHE_HIT === 'true' ? '💾' : '🆕'} |`); | |
| c.push(''); | |
| // ═══════════════════════════════════════════════════════════════════ | |
| // Collapsible Sections | |
| // ═══════════════════════════════════════════════════════════════════ | |
| // 💥 Breaking Changes (open by default) | |
| if (breakingChanges.length > 0) { | |
| c.push('<details open>'); | |
| c.push(`<summary>💥 Breaking changes · ${breakingChanges.length} detected</summary>`); | |
| c.push(''); | |
| c.push('| Symbol | Change |'); | |
| c.push('|:-------|:-------|'); | |
| breakingChanges.slice(0, 8).forEach(b => { | |
| c.push(`| \`${b.symbol || b.name || '?'}\` | ${b.change || b.description || '?'} |`); | |
| }); | |
| if (breakingChanges.length > 8) c.push(`| … | +${breakingChanges.length - 8} more |`); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 🎯 Change Impact Analysis (v7.5 - dogfooding) | |
| if (changedSymbols.length > 0 || affectedSymbols.length > 0) { | |
| const isOpen = impactRisk === 'high' || impactRisk === 'critical'; | |
| c.push(`<details${isOpen ? ' open' : ''}>`); | |
| c.push(`<summary>🎯 Change Impact Analysis · ${impactIcon} ${impactRisk.toUpperCase()} · ${changedSymbols.length} changed → ${affectedSymbols.length} affected</summary>`); | |
| c.push(''); | |
| // Summary stats | |
| c.push('| Metric | Value |'); | |
| c.push('|:-------|------:|'); | |
| c.push(`| Symbols Changed | ${impactSummary.symbolsChanged || 0} |`); | |
| c.push(`| Directly Affected | ${impactSummary.directlyAffected || 0} |`); | |
| c.push(`| Transitively Affected | ${impactSummary.transitivelyAffected || 0} |`); | |
| c.push(`| Modules in Blast Radius | ${impactBlast.moduleCount || 0} |`); | |
| c.push(`| Files in Blast Radius | ${impactBlast.fileCount || 0} |`); | |
| c.push(''); | |
| // Changed symbols (what was modified) | |
| if (changedSymbols.length > 0) { | |
| c.push('**Symbols changed in this PR:**'); | |
| changedSymbols.slice(0, 10).forEach(sym => { | |
| const conf = sym.confidence ? ` (${pct(sym.confidence)}%)` : ''; | |
| const change = sym.changeType ? ` [${sym.changeType}]` : ''; | |
| c.push(`- \`${sym.name || sym.symbolId || '?'}\`${change}${conf} — ${linkedFile(sym.file)}`); | |
| }); | |
| if (changedSymbols.length > 10) c.push(`- … and ${changedSymbols.length - 10} more`); | |
| c.push(''); | |
| } | |
| // Affected symbols (downstream impact) | |
| if (affectedSymbols.length > 0) { | |
| c.push('**Downstream symbols affected:**'); | |
| const directCallers = affectedSymbols.filter(s => s.kind === 'direct-caller' || s.distance === 1); | |
| const transitiveCallers = affectedSymbols.filter(s => s.kind === 'transitive-caller' || (s.distance && s.distance > 1)); | |
| if (directCallers.length > 0) { | |
| c.push(`*Direct callers (${directCallers.length}):*`); | |
| directCallers.slice(0, 5).forEach(sym => { | |
| const mod = sym.moduleId ? ` in \`${sym.moduleId}\`` : ''; | |
| c.push(`- \`${sym.name || sym.stableId || '?'}\`${mod}`); | |
| }); | |
| if (directCallers.length > 5) c.push(`- … and ${directCallers.length - 5} more direct callers`); | |
| } | |
| if (transitiveCallers.length > 0) { | |
| c.push(`*Transitive callers (${transitiveCallers.length}):*`); | |
| transitiveCallers.slice(0, 5).forEach(sym => { | |
| const dist = sym.distance ? ` (depth ${sym.distance})` : ''; | |
| c.push(`- \`${sym.name || sym.stableId || '?'}\`${dist}`); | |
| }); | |
| if (transitiveCallers.length > 5) c.push(`- … and ${transitiveCallers.length - 5} more transitive callers`); | |
| } | |
| c.push(''); | |
| } | |
| // Recommendations | |
| if (impactRecs.length > 0) { | |
| c.push('**Recommendations:**'); | |
| impactRecs.forEach(rec => { | |
| const icon = rec.severity === 'warning' ? '⚠️' : rec.severity === 'error' ? '🔴' : 'ℹ️'; | |
| c.push(`- ${icon} ${rec.message}`); | |
| if (rec.action) c.push(` - *Action:* ${rec.action}`); | |
| }); | |
| c.push(''); | |
| } | |
| // Index staleness warning | |
| if (impact.indexStaleness?.isStale) { | |
| const stale = impact.indexStaleness; | |
| c.push(`> ⚠️ **Index is ${stale.commitsBehind} commit(s) behind HEAD.** Results may be incomplete. Run \`ckb index\` to refresh.`); | |
| c.push(''); | |
| } | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 💣 Blast Radius | |
| if (blastSymbols.length > 0 || blastTests.length > 0 || blastConsumers.length > 0) { | |
| c.push('<details open>'); | |
| c.push(`<summary>💣 Blast radius · ${blastSymbols.length} symbols · ${blastTests.length} tests · ${blastConsumers.length} consumers</summary>`); | |
| c.push(''); | |
| if (blastSymbols.length > 0) { | |
| c.push('**Affected symbols:**'); | |
| blastSymbols.slice(0, 8).forEach(sym => c.push(`- \`${typeof sym === 'string' ? sym : sym.name || sym.symbol || '?'}\``)); | |
| if (blastSymbols.length > 8) c.push(`- … and ${blastSymbols.length - 8} more`); | |
| c.push(''); | |
| } | |
| if (blastTests.length > 0) { | |
| c.push('**Tests that may break:**'); | |
| blastTests.slice(0, 5).forEach(t => c.push(`- \`${typeof t === 'string' ? t : t.name || t.test || '?'}\``)); | |
| if (blastTests.length > 5) c.push(`- … and ${blastTests.length - 5} more`); | |
| c.push(''); | |
| } | |
| if (blastConsumers.length > 0) { | |
| c.push('**Downstream consumers:**'); | |
| blastConsumers.slice(0, 5).forEach(con => c.push(`- \`${typeof con === 'string' ? con : con.name || con.repo || '?'}\``)); | |
| if (blastConsumers.length > 5) c.push(`- … and ${blastConsumers.length - 5} more`); | |
| c.push(''); | |
| } | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // ⚠️ Risk Audit | |
| if (criticalItems.length + highItems.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>⚠️ Risk audit · ${criticalItems.length} critical · ${highItems.length} high</summary>`); | |
| c.push(''); | |
| c.push('| | File | Score | Factor |'); | |
| c.push('|:-:|:-----|------:|:-------|'); | |
| [...criticalItems, ...highItems].slice(0, 8).forEach(item => { | |
| const icon = item.riskLevel === 'critical' ? '🔴' : '🟠'; | |
| const factor = (item.factors || [])[0]?.factor || '—'; | |
| c.push(`| ${icon} | ${linkedFile(item.file)} | ${item.riskScore} | ${factor} |`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 🔥 Hotspots | |
| if (hotspots.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>🔥 Hotspots · ${hotspots.length} volatile files</summary>`); | |
| c.push(''); | |
| c.push('| File | Churn Score |'); | |
| c.push('|:-----|------------:|'); | |
| hotspots.slice(0, 8).forEach(f => { | |
| c.push(`| ${linkedFile(f.path)} | ${(f.hotspotScore || 0).toFixed(2)} |`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 📦 Modules | |
| if (riskyModules.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>📦 Modules · ${riskyModules.length} at risk</summary>`); | |
| c.push(''); | |
| c.push('| | Module | Files |'); | |
| c.push('|:-:|:-------|------:|'); | |
| riskyModules.slice(0, 6).forEach(m => { | |
| const icon = m.riskLevel === 'high' ? '🔴' : '🟡'; | |
| c.push(`| ${icon} | \`${m.moduleId}\` | ${m.filesChanged} |`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 📜 Contracts | |
| if (contracts.files?.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>📜 Contracts · ${contracts.files.length} changed</summary>`); | |
| c.push(''); | |
| contracts.files.slice(0, 10).forEach(f => c.push(`- ${linkedFile(f)}`)); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 📊 Complexity | |
| if (complexViolations.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>📊 Complexity · ${complexViolations.length} violations</summary>`); | |
| c.push(''); | |
| c.push('| File | Cyclomatic | Cognitive |'); | |
| c.push('|:-----|----------:|----------:|'); | |
| complexViolations.slice(0, 8).forEach(v => { | |
| const cyWarn = v.cyclomatic > TH_CY ? '⚠️ ' : ''; | |
| const cgWarn = v.cognitive > TH_CG ? '⚠️ ' : ''; | |
| c.push(`| ${linkedFile(v.file)} | ${cyWarn}${v.cyclomatic} | ${cgWarn}${v.cognitive} |`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 🔗 Coupling | |
| if (coupling.missingCoupled?.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>🔗 Coupling · ${coupling.missingCoupled.length} missing files</summary>`); | |
| c.push(''); | |
| c.push('| Missing File | Usually Changed With | Correlation |'); | |
| c.push('|:-------------|:---------------------|------------:|'); | |
| coupling.missingCoupled.slice(0, 8).forEach(w => { | |
| c.push(`| ${linkedFile(w.file)} | ${linkedFile(w.coupledTo)} | ${pct(w.correlation || w.couplingScore || 0)}% |`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 💡 Quick Wins | |
| if (audit.quickWins?.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>💡 Quick wins · ${audit.quickWins.length} suggestions</summary>`); | |
| c.push(''); | |
| audit.quickWins.slice(0, 8).forEach(w => { | |
| const e = { low: '🟢', medium: '🟡', high: '🔴' }[w.effort] || '⚪'; | |
| c.push(`- ${e} **${w.action}** → ${linkedFile(w.target)}`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 👤 Ownership Drift | |
| const driftedFiles = drift.driftedFiles || []; | |
| if (driftedFiles.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>👤 Ownership drift · ${driftedFiles.length} files</summary>`); | |
| c.push(''); | |
| c.push('| File | CODEOWNERS | Actual Top Contributor |'); | |
| c.push('|:-----|:-----------|:-----------------------|'); | |
| driftedFiles.slice(0, 8).forEach(d => { | |
| c.push(`| ${linkedFile(d.path || d.file)} | ${d.declaredOwner || d.codeowner || '—'} | ${d.actualOwner || d.topContributor || '—'} |`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 💀 Dead Code | |
| if (deadcode.candidates?.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>💀 Dead code · ${deadcode.candidates.length} candidates</summary>`); | |
| c.push(''); | |
| c.push('| Symbol | Confidence | Last Used |'); | |
| c.push('|:-------|:-----------|:----------|'); | |
| deadcode.candidates.slice(0, 8).forEach(d => { | |
| c.push(`| \`${d.name}\` | ${pct(d.confidence || 0)}% | ${d.lastUsed || '—'} |`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 📡 Telemetry Dead Code | |
| if (telemetryDead.candidates?.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>📡 Telemetry dead code · ${telemetryDead.candidates.length} never called in production</summary>`); | |
| c.push(''); | |
| c.push('| Symbol | File | Last Called | Days Since |'); | |
| c.push('|:-------|:-----|:------------|:-----------|'); | |
| telemetryDead.candidates.slice(0, 8).forEach(d => { | |
| c.push(`| \`${d.symbol || d.name}\` | ${linkedFile(d.file || '—')} | ${d.lastCalled || 'Never'} | ${d.daysSinceCall || '∞'} |`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 🧪 Affected Tests | |
| if (affectedTests.tests?.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>🧪 Affected tests · ${affectedTests.tests.length} tests (${affectedTests.strategy || 'unknown'})</summary>`); | |
| c.push(''); | |
| c.push('| Test | Reason |'); | |
| c.push('|:-----|:-------|'); | |
| affectedTests.tests.slice(0, 10).forEach(t => { | |
| const name = typeof t === 'string' ? t : (t.file || t.name || t.test || '?'); | |
| const reason = typeof t === 'object' ? (t.reason || '—') : '—'; | |
| c.push(`| \`${name}\` | ${reason} |`); | |
| }); | |
| if (affectedTests.tests.length > 10) { | |
| c.push(`| … | +${affectedTests.tests.length - 10} more |`); | |
| } | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 📚 Stale Docs | |
| if (docsStale.totalStale > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>📚 Stale docs · ${docsStale.totalStale} broken references</summary>`); | |
| c.push(''); | |
| (docsStale.reports || []).slice(0, 5).forEach(r => { | |
| (r.stale || []).slice(0, 3).forEach(s => { | |
| c.push(`- ${linkedFile(r.docPath, s.line)} → \`${s.rawText}\``); | |
| }); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 🌐 Language Quality | |
| if (lowQualityLangs.length > 0) { | |
| c.push('<details>'); | |
| c.push(`<summary>🌐 Language quality · ${lowQualityLangs.length} below threshold</summary>`); | |
| c.push(''); | |
| c.push('| Language | Quality | Tier | Issues |'); | |
| c.push('|:---------|--------:|:-----|:-------|'); | |
| lowQualityLangs.slice(0, 6).forEach(l => { | |
| const issues = (l.issues || []).slice(0, 2).join(', ') || '—'; | |
| c.push(`| ${l.name} | ${pct(l.quality)}% | ${l.tier || '?'} | ${issues} |`); | |
| }); | |
| c.push(''); | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // 🧪 Eval Suite | |
| const failed = (evalResults.results || evalResults.failedTests || []).filter(r => !r.passed); | |
| if (!evalResults.skipped && (evalResults.totalTests || evalResults.total) > 0) { | |
| const evalPassed = evalResults.passedTests || evalResults.passed || 0; | |
| const evalTotal = evalResults.totalTests || evalResults.total || 0; | |
| const evalPct = Math.round((evalPassed / evalTotal) * 100); | |
| const evalIcon = evalPct >= TH_EVAL ? '✅' : '⚠️'; | |
| c.push('<details>'); | |
| c.push(`<summary>🧪 Eval suite · ${evalIcon} ${evalPassed}/${evalTotal} passed (${evalPct}%)</summary>`); | |
| c.push(''); | |
| if (failed.length > 0) { | |
| c.push('**Failed tests:**'); | |
| const showCount = Math.min(failed.length, 15); | |
| failed.slice(0, showCount).forEach(r => { | |
| c.push(`- \`${r.id || r.name}\`${r.reason ? ` — ${r.reason}` : ''}`); | |
| }); | |
| if (failed.length > showCount) { | |
| c.push(`- … and ${failed.length - showCount} more → [Run Summary](${runUrl})`); | |
| } | |
| c.push(''); | |
| } | |
| c.push('</details>'); | |
| c.push(''); | |
| } | |
| // Footer | |
| c.push('---'); | |
| c.push(`<sub>Generated by <a href="https://github.com/SimplyLiz/CodeMCP">CKB</a> · <a href="${runUrl}">Run details</a></sub>`); | |
| // ═══════════════════════════════════════════════════════════════════ | |
| // Post Comment | |
| // ═══════════════════════════════════════════════════════════════════ | |
| let body = c.join('\n'); | |
| // Hard cap at 65k | |
| if (body.length > 65000) { | |
| body = body.slice(0, 64800) + `\n\n---\n<sub>✂️ Truncated. <a href="${runUrl}">Full report</a></sub>`; | |
| } | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number | |
| }); | |
| const existing = comments.find(comment => | |
| comment.user?.type === 'Bot' && comment.body?.includes('<!-- ckb -->') | |
| ); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body | |
| }); | |
| console.log(`Updated comment ${existing.id}`); | |
| } else { | |
| const { data: newComment } = await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body | |
| }); | |
| console.log(`Created comment ${newComment.id}`); | |
| } | |
| // Write full failed tests to Step Summary if > 15 | |
| if (failed.length > 15) { | |
| let summary = `## 🧪 Failed Eval Tests (${failed.length})\n\n`; | |
| summary += failed.map(r => `- \`${r.id || r.name}\`${r.reason ? ` — ${r.reason}` : ''}`).join('\n'); | |
| await core.summary.addRaw(summary + '\n', true).write(); | |
| } | |
| # ─────────────────────────────────────────────────────────────────────── | |
| # Auto-Assign Reviewers | |
| # ─────────────────────────────────────────────────────────────────────── | |
| - name: Reviewers | |
| if: always() | |
| continue-on-error: true | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| try { | |
| const { suggestedReviewers = [] } = JSON.parse(fs.readFileSync('analysis.json')); | |
| const reviewers = suggestedReviewers | |
| .map(r => r.owner.replace(/^@/, '')) | |
| .filter(r => !r.includes('/') && r !== context.actor) | |
| .slice(0, 2); | |
| if (reviewers.length) { | |
| await github.rest.pulls.requestReviewers({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: context.issue.number, | |
| reviewers | |
| }); | |
| } | |
| } catch {} | |
| # ─────────────────────────────────────────────────────────────────────── | |
| # Artifacts | |
| # ─────────────────────────────────────────────────────────────────────── | |
| - name: Save Cache | |
| if: always() | |
| uses: actions/cache/save@v5 | |
| with: | |
| path: .ckb/ | |
| key: ckb-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ github.base_ref }} | |
| - name: Upload | |
| if: always() | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: ckb-analysis | |
| path: '*.json' | |
| retention-days: 7 | |
| # ╔═════════════════════════════════════════════════════════════════════════╗ | |
| # ║ SCHEDULED REFRESH ║ | |
| # ╚═════════════════════════════════════════════════════════════════════════╝ | |
| refresh: | |
| name: Refresh | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-go@v6 | |
| with: | |
| go-version-file: 'go.mod' | |
| cache: true | |
| - name: Build | |
| run: go build -ldflags="-s -w" -o ckb ./cmd/ckb | |
| - name: Install SCIP indexer | |
| run: go install github.com/sourcegraph/scip-go/cmd/scip-go@latest | |
| - name: Cache | |
| uses: actions/cache@v5 | |
| with: | |
| path: .ckb/ | |
| key: ckb-${{ runner.os }}-refresh-${{ github.run_id }} | |
| restore-keys: ckb-${{ runner.os }}- | |
| - name: Refresh | |
| run: | | |
| ./ckb init | |
| if ! ./ckb index --force 2>&1 | tee /tmp/index.log; then | |
| if grep -q "Indexer not found" /tmp/index.log; then | |
| echo "::error::SCIP indexer not installed" | |
| exit 1 | |
| fi | |
| echo "::warning::Indexing failed" | |
| fi | |
| mkdir -p reports | |
| ./ckb hotspots --limit=50 --format=json > reports/hotspots.json 2>/dev/null || echo '[]' > reports/hotspots.json | |
| ./ckb audit --min-score=40 --limit=50 --format=json > reports/audit.json 2>/dev/null || echo '{}' > reports/audit.json | |
| ./ckb ownership drift --format=json > reports/drift.json 2>/dev/null || echo '[]' > reports/drift.json | |
| ./ckb doctor --format=json > reports/doctor.json 2>/dev/null || echo '{"languages":[]}' > reports/doctor.json | |
| jq '{ | |
| languages: [(.languages // [])[] | {name, quality: (if .status == "ready" then 1 else 0.5 end), tier, status}], | |
| overallQuality: ([(.languages // [])[] | if .status == "ready" then 1 else 0 end] | if length > 0 then add / length else 1 end) | |
| }' reports/doctor.json > reports/languages.json 2>/dev/null || echo '{"languages":[],"overallQuality":1}' > reports/languages.json | |
| ./ckb dead-code --min-confidence=0.8 --limit=20 --format=json > reports/deadcode.json 2>/dev/null || echo '{"candidates":[]}' > reports/deadcode.json | |
| - name: Summary | |
| run: | | |
| echo "## 📊 CKB Nightly Refresh" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY | |
| echo "|--------|------:|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Hotspots | $(jq 'if type == "array" then length else 0 end' reports/hotspots.json) |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Critical Risk | $(jq '.summary.critical // 0' reports/audit.json) |" >> $GITHUB_STEP_SUMMARY | |
| echo "| High Risk | $(jq '.summary.high // 0' reports/audit.json) |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Ownership Drift | $(jq 'if type == "array" then length else 0 end' reports/drift.json) |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Dead Code | $(jq '.candidates | if type == "array" then length else 0 end' reports/deadcode.json) |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Language Quality | $(jq '.overallQuality * 100 | floor' reports/languages.json)% |" >> $GITHUB_STEP_SUMMARY | |
| - name: Upload | |
| uses: actions/upload-artifact@v6 | |
| with: | |
| name: ckb-refresh | |
| path: reports/ | |
| retention-days: 30 |