From d571e2e8b5182bf2f58858bd8efc83a1c9cf863c Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 14:30:27 +0100 Subject: [PATCH] feat: comprehensive governance dashboard with tabs, policy view, and compliance scoring --- src/dashboard.js | 487 +++++++++++++++++++++++++++++++---------------- 1 file changed, 327 insertions(+), 160 deletions(-) diff --git a/src/dashboard.js b/src/dashboard.js index 3d12ecf..a3bab3c 100644 --- a/src/dashboard.js +++ b/src/dashboard.js @@ -6,7 +6,7 @@ import { Octokit } from '@octokit/rest'; // ── Cache ──────────────────────────────────────────────────────────── const cache = { data: null, timestamp: 0 }; -const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const CACHE_TTL = 5 * 60 * 1000; function isCacheValid() { return cache.data && (Date.now() - cache.timestamp) < CACHE_TTL; @@ -17,33 +17,22 @@ export function invalidateCache() { cache.timestamp = 0; } -// ── Octokit Factory ────────────────────────────────────────────────── +// ── Octokit ────────────────────────────────────────────────────────── let _octokit = null; async function getOctokit() { if (_octokit) return _octokit; - const appId = process.env.APP_ID; const privateKey = (process.env.PRIVATE_KEY || '').replace(/\\n/g, '\n'); - - const appOctokit = new Octokit({ - authStrategy: createAppAuth, - auth: { appId, privateKey } - }); - + const appOctokit = new Octokit({ authStrategy: createAppAuth, auth: { appId, privateKey } }); const { data: installations } = await appOctokit.apps.listInstallations(); const inst = installations.find(i => i.account?.login === getConfig()?.organization); if (!inst) throw new Error('No installation found for org'); - - _octokit = new Octokit({ - authStrategy: createAppAuth, - auth: { appId, privateKey, installationId: inst.id } - }); - + _octokit = new Octokit({ authStrategy: createAppAuth, auth: { appId, privateKey, installationId: inst.id } }); return _octokit; } -// ── Data Fetching ──────────────────────────────────────────────────── +// ── Data ───────────────────────────────────────────────────────────── async function fetchOrgData() { if (isCacheValid()) return cache.data; @@ -56,26 +45,34 @@ async function fetchOrgData() { const repos = analysis.repositories; - // Enrich with CI status for (const repo of repos) { + // CI status try { - const { data: runs } = await octokit.actions.listWorkflowRunsForRepo({ - owner: org, repo: repo.name, per_page: 1 - }); + const { data: runs } = await octokit.actions.listWorkflowRunsForRepo({ owner: org, repo: repo.name, per_page: 1 }); const run = runs.workflow_runs?.[0]; - repo.ci_status = run - ? { status: run.conclusion || run.status, updated_at: run.updated_at } - : { status: 'none' }; - } catch { - repo.ci_status = { status: 'none' }; - } + repo.ci_status = run ? { status: run.conclusion || run.status, updated_at: run.updated_at } : { status: 'none' }; + } catch { repo.ci_status = { status: 'none' }; } + + // Signed commits + auto-merge (need separate API calls) + try { + const { data: repoData } = await octokit.repos.get({ owner: org, repo: repo.name }); + repo.auto_merge_enabled = repoData.allow_auto_merge || false; + } catch { repo.auto_merge_enabled = false; } + + try { + await octokit.repos.getCommitSignatureProtection({ + owner: org, repo: repo.name, branch: repo.configurations?.default_branch || 'main' + }); + repo.signed_commits_required = true; + } catch { repo.signed_commits_required = false; } } const result = { org, + config, timestamp: new Date().toISOString(), repos: repos.sort((a, b) => a.name.localeCompare(b.name)), - summary: buildSummary(repos) + summary: buildSummary(repos, config) }; cache.data = result; @@ -83,51 +80,63 @@ async function fetchOrgData() { return result; } -function buildSummary(repos) { +function buildSummary(repos, config) { const total = repos.length; + const targetMerge = config?.settings?.merge || {}; const withProtection = repos.filter(r => r.configurations?.branch_protection?.exists).length; const withCI = repos.filter(r => r.ci_status?.status && r.ci_status.status !== 'none').length; - const withSigned = repos.filter(r => r.configurations?.branch_protection?.require_signed_commits).length; + const ciPassing = repos.filter(r => r.ci_status?.status === 'success').length; + const withSigned = repos.filter(r => r.signed_commits_required).length; + const withAutoMerge = repos.filter(r => r.auto_merge_enabled).length; + const targetLabels = (config?.issue_labels || []).length; + const withAllLabels = repos.filter(r => (r.configurations?.labels?.standard_labels?.length || 0) >= targetLabels).length; + const correctMerge = repos.filter(r => { const m = r.configurations?.merge_settings; - return m && !m.allow_merge_commit && !m.allow_squash_merge && m.allow_rebase_merge && m.delete_branch_on_merge; + if (!m) return false; + return (!!m.allow_merge_commit === !!targetMerge.allow_merge_commit) && + (!!m.allow_squash_merge === !!targetMerge.allow_squash_merge) && + (!!m.allow_rebase_merge === !!targetMerge.allow_rebase_merge); }).length; const issues = []; repos.forEach(r => { - if (!r.configurations?.branch_protection?.exists) issues.push(`${r.name}: no branch protection`); + if (!r.configurations?.branch_protection?.exists) issues.push({ repo: r.name, type: 'protection', msg: 'No branch protection' }); const m = r.configurations?.merge_settings; - if (m && (m.allow_merge_commit || m.allow_squash_merge || !m.allow_rebase_merge)) { - issues.push(`${r.name}: non-standard merge settings`); + if (m && ((!!m.allow_merge_commit !== !!targetMerge.allow_merge_commit) || + (!!m.allow_squash_merge !== !!targetMerge.allow_squash_merge) || + (!!m.allow_rebase_merge !== !!targetMerge.allow_rebase_merge))) { + issues.push({ repo: r.name, type: 'merge', msg: 'Merge settings drift' }); + } + if (!r.signed_commits_required) issues.push({ repo: r.name, type: 'signed', msg: 'Signed commits not required' }); + if (!r.auto_merge_enabled) issues.push({ repo: r.name, type: 'auto-merge', msg: 'Auto-merge not enabled' }); + if ((r.configurations?.labels?.standard_labels?.length || 0) < targetLabels) { + issues.push({ repo: r.name, type: 'labels', msg: `Missing ${targetLabels - (r.configurations?.labels?.standard_labels?.length || 0)} labels` }); } }); - return { total, withProtection, withCI, withSigned, correctMerge, issues }; + return { total, withProtection, withCI, ciPassing, withSigned, withAutoMerge, correctMerge, withAllLabels, targetLabels, issues }; } -// ── HTML Rendering ─────────────────────────────────────────────────── -function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } - +// ── Rendering helpers ──────────────────────────────────────────────── +function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function badge(cls, label) { return `${esc(label)}`; } +function okBadge(ok, yesLabel, noLabel) { return ok ? badge('badge-ok', yesLabel || 'yes') : badge('badge-warn', noLabel || 'no'); } function ciBadge(status) { - const map = { - success: ['badge-ok', 'pass'], failure: ['badge-err', 'fail'], - cancelled: ['badge-warn', 'cancelled'], in_progress: ['badge-info', 'running'], - queued: ['badge-info', 'queued'], none: ['badge-muted', 'none'] - }; + const map = { success: ['badge-ok','pass'], failure: ['badge-err','fail'], cancelled: ['badge-warn','cancelled'], + in_progress: ['badge-info','running'], queued: ['badge-info','queued'], none: ['badge-muted','none'] }; const [cls, label] = map[status] || ['badge-muted', status || '?']; return badge(cls, label); } function mergeLabel(settings) { if (!settings || settings.error) return badge('badge-muted', '?'); - if (settings.allow_rebase_merge && !settings.allow_merge_commit && !settings.allow_squash_merge) return badge('badge-ok', 'rebase'); const parts = []; - if (settings.allow_rebase_merge) parts.push('rebase'); if (settings.allow_merge_commit) parts.push('merge'); if (settings.allow_squash_merge) parts.push('squash'); - return badge('badge-warn', parts.join('+')); + if (settings.allow_rebase_merge) parts.push('rebase'); + return badge(parts.length <= 2 ? 'badge-ok' : 'badge-warn', parts.join('+') || 'none'); } function timeAgo(dateStr) { @@ -140,103 +149,236 @@ function timeAgo(dateStr) { return `${Math.floor(hrs / 24)}d ago`; } +function pct(n, total) { return total === 0 ? 0 : Math.round(n / total * 100); } + +// ── Partials ───────────────────────────────────────────────────────── function renderSummaryPartial(data) { const s = data.summary; - return ` -
-
${s.total}
Total Repos
-
${s.withProtection}
Protected
-
${s.withCI}
With CI
-
${s.withSigned}
Signed Commits
-
${s.correctMerge}
Correct Merge
-
${s.issues.length}
Issues
-
`; + const t = s.total; + function card(value, label, cls) { + return `
${value}
${label}
`; + } + const score = t === 0 ? 0 : Math.round(((s.withProtection + s.correctMerge + s.withSigned + s.withAutoMerge + s.withAllLabels) / (t * 5)) * 100); + const scoreCls = score >= 90 ? 'card-ok' : score >= 70 ? 'card-warn' : 'card-err'; + + return `
+ ${card(score + '%', 'Compliance', scoreCls)} + ${card(t, 'Total Repos', '')} + ${card(s.withProtection, 'Protected', s.withProtection === t ? 'card-ok' : 'card-warn')} + ${card(s.ciPassing + '/' + s.withCI, 'CI Pass/Total', s.ciPassing === t ? 'card-ok' : s.withCI > 0 ? 'card-warn' : 'card-err')} + ${card(s.withSigned, 'Signed', s.withSigned === t ? 'card-ok' : 'card-warn')} + ${card(s.correctMerge, 'Correct Merge', s.correctMerge === t ? 'card-ok' : 'card-warn')} + ${card(s.withAutoMerge, 'Auto-Merge', s.withAutoMerge === t ? 'card-ok' : 'card-warn')} + ${card(s.withAllLabels, 'Labels OK', s.withAllLabels === t ? 'card-ok' : 'card-warn')} +
`; +} + +function renderPolicyPartial(data) { + const config = data.config || {}; + const merge = config?.settings?.merge || {}; + const bp = config?.branch_protection?.default || {}; + const am = config?.auto_merge || {}; + const checks = bp?.required_status_checks?.contexts || []; + const labels = (config?.issue_labels || []).map(l => l.name); + + function row(label, value, detail) { + return `${esc(label)}${value}${detail ? `${esc(detail)}` : ''}`; + } + + return `
+
+

Merge Strategy

+ + ${row('Merge commit', okBadge(merge.allow_merge_commit, 'allowed', 'blocked'), 'Preserves commit signatures')} + ${row('Squash merge', okBadge(merge.allow_squash_merge, 'allowed', 'blocked'), 'GitHub signs squash commits')} + ${row('Rebase merge', merge.allow_rebase_merge ? badge('badge-warn', 'allowed') : badge('badge-ok', 'blocked'), 'Cannot be signed by GitHub')} + ${row('Delete branch', okBadge(merge.delete_branch_on_merge), '')} + ${row('Auto-merge', okBadge(merge.allow_auto_merge), '')} +
+
+
+

Branch Protection

+ + ${row('Enforce admins', bp.enforce_admins ? badge('badge-warn', 'yes') : badge('badge-ok', 'no'), bp.enforce_admins ? 'Admins subject to rules' : 'Admin can bypass')} + ${row('PR reviews', bp.required_pull_request_reviews === null ? badge('badge-ok', 'none') : badge('badge-warn', JSON.stringify(bp.required_pull_request_reviews)), '')} + ${row('Signed commits', okBadge(bp.require_signed_commits, 'required', 'not required'), '')} + ${row('Linear history', okBadge(bp.required_linear_history, 'required', 'not required'), '')} + ${row('Force pushes', bp.allow_force_pushes ? badge('badge-err', 'allowed') : badge('badge-ok', 'blocked'), '')} + ${row('Deletions', bp.allow_deletions ? badge('badge-err', 'allowed') : badge('badge-ok', 'blocked'), '')} + ${row('Status checks', checks.length > 0 ? badge('badge-info', checks.join(', ')) : badge('badge-muted', 'none configured'), '')} +
+
+
+

Auto-Merge Rules

+ + ${row('Enabled', okBadge(am.enabled), '')} + ${row('Dependabot PRs', okBadge(am.on_dependabot), '')} + ${row('Bot users', (am.on_bot_users || []).length > 0 ? badge('badge-info', (am.on_bot_users || []).join(', ')) : badge('badge-muted', 'none'), '')} + ${row('Method', badge('badge-info', am.merge_method || 'squash'), '')} +
+
+
+

Standard Labels (${labels.length})

+
${labels.map(l => `${esc(l)}`).join(' ')}
+
+
`; } function renderReposPartial(data) { const rows = data.repos.map(r => { const bp = r.configurations?.branch_protection || {}; const ms = r.configurations?.merge_settings || {}; + const dep = r.configurations?.dependabot || {}; + const lbls = r.configurations?.labels || {}; + const targetLabels = (data.config?.issue_labels || []).length; + const hasAllLabels = (lbls.standard_labels?.length || 0) >= targetLabels; + return ` - ${esc(r.name)} - ${bp.exists ? badge('badge-ok', 'yes') : badge('badge-warn', 'no')} - ${bp.enforce_admins ? badge('badge-warn', 'enforced') : badge('badge-ok', 'off')} - ${bp.required_reviews > 0 ? badge('badge-warn', bp.required_reviews + ' review') : badge('badge-ok', 'none')} - ${ciBadge(r.ci_status?.status)} - ${mergeLabel(ms)} - ${esc(timeAgo(r.updated_at))} - `; + ${esc(r.name)} + ${okBadge(bp.exists)} + ${okBadge(r.signed_commits_required)} + ${ciBadge(r.ci_status?.status)} + ${mergeLabel(ms)} + ${okBadge(r.auto_merge_enabled)} + ${okBadge(dep.exists)} + ${hasAllLabels ? badge('badge-ok', lbls.standard_labels?.length + '/' + targetLabels) : badge('badge-warn', (lbls.standard_labels?.length || 0) + '/' + targetLabels)} + ${esc(timeAgo(r.updated_at))} + `; }).join(''); return ` - - - - + + + + + ${rows} +
RepositoryProtectedEnforce AdminReviewsCIMergeUpdated
RepositoryProtectedSignedCIMergeAutoDependabotLabelsUpdated
`; +} + +function renderIssuesPartial(data) { + const issues = data.summary.issues; + if (issues.length === 0) { + return `
${badge('badge-ok', 'All repos compliant')} No issues detected.
`; + } + + // Group by repo + const byRepo = {}; + issues.forEach(i => { + if (!byRepo[i.repo]) byRepo[i.repo] = []; + byRepo[i.repo].push(i); + }); + + const typeIcon = { protection: 'shield', merge: 'git-merge', signed: 'key', 'auto-merge': 'zap', labels: 'tag' }; + const rows = Object.entries(byRepo).map(([repo, items]) => { + const badges = items.map(i => { + const cls = i.type === 'protection' ? 'badge-err' : 'badge-warn'; + return badge(cls, i.msg); + }).join(' '); + return `${esc(repo)}${badges}`; + }).join(''); + + return `
${badge('badge-err', issues.length + ' issues')} across ${Object.keys(byRepo).length} repos
+ + ${rows} -
RepositoryIssues
`; + +
Run Sync All Repos to apply config to drifted repos.
`; } +// ── Main page ──────────────────────────────────────────────────────── function renderDashboardPage() { return ` -Temper Dashboard +Temper — Governance Dashboard @@ -245,145 +387,170 @@ header h1{font-size:20px;color:var(--accent)}

temper

+ governance dashboard
+
-
-

Actions

-
- - -
-
+ +
+ + +
-
-

Repositories

+ +
+
+
+ + +
+
+
+ +
+
+
+ +
+
+
+ `; } -// ── Handler for Probot's addHandler pattern ────────────────────────── -// Returns async (req, res) => boolean, matching Probot v14's handler chain - +// ── Request handler ────────────────────────────────────────────────── function sendHtml(res, status, html) { res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } - function sendJson(res, status, obj) { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); } +function redirect(res, location) { + res.writeHead(302, { 'Location': location }); + res.end(); +} export function createDashboardHandler() { return async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const path = url.pathname; - // GET /dashboard — main page + // Root redirect + if (req.method === 'GET' && (path === '/' || path === '')) { + redirect(res, '/dashboard'); + return true; + } + if (req.method === 'GET' && path === '/dashboard') { sendHtml(res, 200, renderDashboardPage()); return true; } - // GET /dashboard/partials/summary if (req.method === 'GET' && path === '/dashboard/partials/summary') { - try { - const data = await fetchOrgData(); - sendHtml(res, 200, renderSummaryPartial(data)); - } catch (err) { - getLogger().error({ err }, 'Dashboard summary error'); - sendHtml(res, 500, '
Error loading summary
'); - } + try { const data = await fetchOrgData(); sendHtml(res, 200, renderSummaryPartial(data)); } + catch (err) { getLogger().error({ err }, 'Dashboard summary error'); sendHtml(res, 500, '
Error loading
'); } return true; } - // GET /dashboard/partials/repos if (req.method === 'GET' && path === '/dashboard/partials/repos') { - try { - const data = await fetchOrgData(); - sendHtml(res, 200, renderReposPartial(data)); - } catch (err) { - getLogger().error({ err }, 'Dashboard repos error'); - sendHtml(res, 500, '
Error loading repos
'); - } + try { const data = await fetchOrgData(); sendHtml(res, 200, renderReposPartial(data)); } + catch (err) { getLogger().error({ err }, 'Dashboard repos error'); sendHtml(res, 500, '
Error loading
'); } + return true; + } + + if (req.method === 'GET' && path === '/dashboard/partials/policy') { + try { const data = await fetchOrgData(); sendHtml(res, 200, renderPolicyPartial(data)); } + catch (err) { getLogger().error({ err }, 'Dashboard policy error'); sendHtml(res, 500, '
Error loading
'); } + return true; + } + + if (req.method === 'GET' && path === '/dashboard/partials/issues') { + try { const data = await fetchOrgData(); sendHtml(res, 200, renderIssuesPartial(data)); } + catch (err) { getLogger().error({ err }, 'Dashboard issues error'); sendHtml(res, 500, '
Error loading
'); } return true; } - // GET /api/org/health — JSON summary if (req.method === 'GET' && path === '/api/org/health') { - try { - const data = await fetchOrgData(); - sendJson(res, 200, { success: true, ...data.summary, timestamp: data.timestamp }); - } catch (err) { - sendJson(res, 500, { success: false, error: err.message }); - } + try { const data = await fetchOrgData(); sendJson(res, 200, { success: true, ...data.summary, timestamp: data.timestamp }); } + catch (err) { sendJson(res, 500, { success: false, error: err.message }); } return true; } - // GET /api/org/repos — JSON repo list if (req.method === 'GET' && path === '/api/org/repos') { - try { - const data = await fetchOrgData(); - sendJson(res, 200, { success: true, repos: data.repos }); - } catch (err) { - sendJson(res, 500, { success: false, error: err.message }); - } + try { const data = await fetchOrgData(); sendJson(res, 200, { success: true, repos: data.repos }); } + catch (err) { sendJson(res, 500, { success: false, error: err.message }); } return true; } - // POST /dashboard/actions/refresh if (req.method === 'POST' && path === '/dashboard/actions/refresh') { invalidateCache(); - try { - await fetchOrgData(); - sendJson(res, 200, { success: true, message: 'Cache refreshed' }); - } catch (err) { - sendJson(res, 500, { success: false, message: err.message }); - } + try { await fetchOrgData(); sendJson(res, 200, { success: true, message: 'Cache refreshed' }); } + catch (err) { sendJson(res, 500, { success: false, message: err.message }); } return true; } - // POST /dashboard/actions/sync if (req.method === 'POST' && path === '/dashboard/actions/sync') { try { const octokit = await getOctokit(); const org = getConfig()?.organization || 'pulseengine'; const result = await synchronizeAllRepositories(octokit, org); invalidateCache(); - sendJson(res, 200, { - success: result.success, - message: result.success - ? `Synchronized ${result.repositoriesProcessed} repositories` - : result.error - }); - } catch (err) { - sendJson(res, 500, { success: false, message: err.message }); - } + sendJson(res, 200, { success: result.success, message: result.success ? `Synchronized ${result.repositoriesProcessed} repositories` : result.error }); + } catch (err) { sendJson(res, 500, { success: false, message: err.message }); } return true; }