From 58df198808de361d5a8fc181838784758e77fd2a Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 13:55:59 +0100 Subject: [PATCH] feat: add governance dashboard and CLI tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard (src/dashboard.js): - HTMX-powered web dashboard at /dashboard with dark theme - Summary cards: total repos, protection, CI, signed commits, issues - Per-repo table with protection, CI status, merge strategy - 5-minute in-memory cache for GitHub API data - Action buttons: Sync All Repos, Refresh Cache - JSON API endpoints: /api/org/health, /api/org/repos - Uses Probot v14 addHandler pattern (not Express router) CLI (scripts/cli.js): - node scripts/cli.js org health — org summary table - node scripts/cli.js org repos — detailed per-repo status - node scripts/cli.js repo check — single repo check - node scripts/cli.js sync — sync all repos to config - Colored terminal output with status badges --- scripts/cli.js | 258 +++++++++++++++++++++++++++++++ src/app.js | 3 + src/dashboard.js | 392 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 653 insertions(+) create mode 100644 scripts/cli.js create mode 100644 src/dashboard.js diff --git a/scripts/cli.js b/scripts/cli.js new file mode 100644 index 0000000..677f468 --- /dev/null +++ b/scripts/cli.js @@ -0,0 +1,258 @@ +#!/usr/bin/env node + +import { config as dotenvConfig } from 'dotenv'; +import { createAppAuth } from '@octokit/auth-app'; +import { Octokit } from '@octokit/rest'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenvConfig({ path: path.join(__dirname, '..', '.env'), quiet: true }); + +// ── Octokit Setup ──────────────────────────────────────────────────── +async function getOctokit() { + const appId = process.env.APP_ID; + const privateKey = (process.env.PRIVATE_KEY || '').replace(/\\n/g, '\n'); + const org = process.env.ORGANIZATION || 'pulseengine'; + + const appOctokit = new Octokit({ + authStrategy: createAppAuth, + auth: { appId, privateKey } + }); + + const { data: installations } = await appOctokit.apps.listInstallations(); + const inst = installations.find(i => i.account?.login === org); + if (!inst) throw new Error(`No installation found for org: ${org}`); + + return new Octokit({ + authStrategy: createAppAuth, + auth: { appId, privateKey, installationId: inst.id } + }); +} + +// ── Formatting ─────────────────────────────────────────────────────── +const C = { + reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', + green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', + cyan: '\x1b[36m', blue: '\x1b[34m', gray: '\x1b[90m' +}; + +function ok(s) { return `${C.green}${s}${C.reset}`; } +function warn(s) { return `${C.yellow}${s}${C.reset}`; } +function err(s) { return `${C.red}${s}${C.reset}`; } +function dim(s) { return `${C.gray}${s}${C.reset}`; } +function bold(s) { return `${C.bold}${s}${C.reset}`; } + +function padEnd(s, n) { return String(s).padEnd(n); } + +// ── Analyze ────────────────────────────────────────────────────────── +async function analyzeOrg(octokit, org) { + const repos = await octokit.paginate('GET /orgs/{org}/repos', { + org, type: 'all', per_page: 100 + }); + + const results = []; + for (const repo of repos) { + if (repo.archived || repo.fork) continue; + + const r = { name: repo.name, updated: repo.updated_at }; + + // merge settings + r.merge = { + commit: repo.allow_merge_commit, + squash: repo.allow_squash_merge, + rebase: repo.allow_rebase_merge, + deleteBranch: repo.delete_branch_on_merge + }; + + // branch protection + try { + const { data: bp } = await octokit.repos.getBranchProtection({ + owner: org, repo: repo.name, branch: repo.default_branch || 'main' + }); + r.protection = { + exists: true, + checks: bp.required_status_checks?.contexts || [], + enforceAdmins: bp.enforce_admins?.enabled, + reviews: bp.required_pull_request_reviews?.required_approving_review_count || 0, + signed: false + }; + // Check signed commits separately + try { + await octokit.repos.getCommitSignatureProtection({ + owner: org, repo: repo.name, branch: repo.default_branch || 'main' + }); + r.protection.signed = true; + } catch { r.protection.signed = false; } + } catch { + r.protection = { exists: false, checks: [], enforceAdmins: false, reviews: 0, signed: false }; + } + + // CI status + try { + const { data: runs } = await octokit.actions.listWorkflowRunsForRepo({ + owner: org, repo: repo.name, per_page: 1 + }); + const run = runs.workflow_runs?.[0]; + r.ci = run ? (run.conclusion || run.status) : 'none'; + } catch { r.ci = 'none'; } + + results.push(r); + } + + return results.sort((a, b) => a.name.localeCompare(b.name)); +} + +// ── Commands ───────────────────────────────────────────────────────── +async function cmdOrgHealth(octokit, org) { + console.log(bold(`\n Temper — ${org} org health\n`)); + const repos = await analyzeOrg(octokit, org); + + const total = repos.length; + const prot = repos.filter(r => r.protection.exists).length; + const ci = repos.filter(r => r.ci !== 'none').length; + const signed = repos.filter(r => r.protection.signed).length; + const correctMerge = repos.filter(r => r.merge.rebase && !r.merge.commit && !r.merge.squash).length; + const issues = repos.filter(r => !r.protection.exists || r.merge.commit || r.merge.squash || !r.merge.rebase); + + console.log(` ${bold('Total repos:')} ${total}`); + console.log(` ${bold('Protected:')} ${prot === total ? ok(prot) : warn(prot)}/${total}`); + console.log(` ${bold('With CI:')} ${ci > 0 ? ok(ci) : warn(ci)}/${total}`); + console.log(` ${bold('Signed commits:')} ${signed}/${total}`); + console.log(` ${bold('Correct merge:')} ${correctMerge === total ? ok(correctMerge) : warn(correctMerge)}/${total}`); + console.log(` ${bold('Issues:')} ${issues.length === 0 ? ok('0') : err(issues.length)}`); + + if (issues.length > 0) { + console.log(`\n ${bold('Issues:')}`); + issues.forEach(r => { + const probs = []; + if (!r.protection.exists) probs.push('no protection'); + if (r.merge.commit) probs.push('merge commit allowed'); + if (r.merge.squash) probs.push('squash allowed'); + if (!r.merge.rebase) probs.push('rebase disabled'); + console.log(` ${warn(r.name)}: ${probs.join(', ')}`); + }); + } + console.log(); +} + +async function cmdOrgRepos(octokit, org) { + console.log(bold(`\n Temper — ${org} repositories\n`)); + const repos = await analyzeOrg(octokit, org); + + // Header + console.log(` ${dim(padEnd('REPOSITORY', 28))}${dim(padEnd('PROT', 6))}${dim(padEnd('ADMIN', 8))}${dim(padEnd('REVIEW', 8))}${dim(padEnd('CI', 12))}${dim(padEnd('MERGE', 14))}${dim('SIGNED')}`); + console.log(` ${dim('─'.repeat(84))}`); + + repos.forEach(r => { + const prot = r.protection.exists ? ok('yes') : warn('no'); + const admin = r.protection.enforceAdmins ? warn('yes') : ok('no'); + const review = r.protection.reviews > 0 ? warn(r.protection.reviews) : ok('0'); + const ciMap = { success: ok('pass'), failure: err('fail'), none: dim('none') }; + const ci = ciMap[r.ci] || dim(r.ci); + const merge = r.merge.rebase && !r.merge.commit && !r.merge.squash + ? ok('rebase') + : warn([r.merge.rebase && 'rebase', r.merge.commit && 'merge', r.merge.squash && 'squash'].filter(Boolean).join('+')); + const signed = r.protection.signed ? ok('yes') : dim('no'); + + console.log(` ${C.cyan}${padEnd(r.name, 28)}${C.reset}${padEnd(prot, 16)}${padEnd(admin, 18)}${padEnd(review, 18)}${padEnd(ci, 22)}${padEnd(merge, 24)}${signed}`); + }); + console.log(); +} + +async function cmdRepoCheck(octokit, org, repoName) { + console.log(bold(`\n Temper — ${org}/${repoName} check\n`)); + + try { + const { data: repo } = await octokit.repos.get({ owner: org, repo: repoName }); + + console.log(` ${bold('Merge settings:')}`); + console.log(` Merge commit: ${repo.allow_merge_commit ? warn('enabled') : ok('disabled')}`); + console.log(` Squash merge: ${repo.allow_squash_merge ? warn('enabled') : ok('disabled')}`); + console.log(` Rebase merge: ${repo.allow_rebase_merge ? ok('enabled') : warn('disabled')}`); + console.log(` Delete branch: ${repo.delete_branch_on_merge ? ok('enabled') : warn('disabled')}`); + + const branch = repo.default_branch || 'main'; + console.log(`\n ${bold('Branch protection:')} (${branch})`); + try { + const { data: bp } = await octokit.repos.getBranchProtection({ + owner: org, repo: repoName, branch + }); + console.log(` Status checks: ${(bp.required_status_checks?.contexts || []).join(', ') || dim('none')}`); + console.log(` Enforce admin: ${bp.enforce_admins?.enabled ? warn('yes') : ok('no')}`); + console.log(` Reviews: ${bp.required_pull_request_reviews ? warn(bp.required_pull_request_reviews.required_approving_review_count) : ok('none')}`); + + try { + await octokit.repos.getCommitSignatureProtection({ owner: org, repo: repoName, branch }); + console.log(` Signed: ${ok('required')}`); + } catch { console.log(` Signed: ${dim('not required')}`); } + } catch { + console.log(` ${warn('No branch protection configured')}`); + } + + // CI + console.log(`\n ${bold('CI status:')}`); + try { + const { data: runs } = await octokit.actions.listWorkflowRunsForRepo({ + owner: org, repo: repoName, per_page: 3 + }); + if (runs.workflow_runs.length === 0) { + console.log(` ${dim('No workflow runs found')}`); + } else { + runs.workflow_runs.forEach(run => { + const status = run.conclusion === 'success' ? ok('pass') : run.conclusion === 'failure' ? err('fail') : dim(run.conclusion || run.status); + console.log(` ${padEnd(run.name, 20)} ${status} ${dim(run.updated_at)}`); + }); + } + } catch { console.log(` ${dim('Could not fetch CI runs')}`); } + } catch (e) { + console.error(err(` Error: ${e.message}`)); + } + console.log(); +} + +async function cmdSync(octokit, org) { + console.log(bold(`\n Temper — syncing all repos in ${org}...\n`)); + + const { synchronizeAllRepositories } = await import('../src/organization.js'); + const result = await synchronizeAllRepositories(octokit, org); + + if (result.success) { + console.log(ok(` Synchronized ${result.repositoriesProcessed} repositories\n`)); + } else { + console.error(err(` Sync failed: ${result.error}\n`)); + } +} + +// ── Main ───────────────────────────────────────────────────────────── +async function main() { + const args = process.argv.slice(2); + const cmd = args[0]; + const sub = args[1]; + + if (!cmd) { + console.log(` + ${bold('Temper CLI')} + + Usage: + node scripts/cli.js org health Summary of all repos + node scripts/cli.js org repos Detailed per-repo table + node scripts/cli.js repo check Check a single repo + node scripts/cli.js sync Sync all repos to config +`); + process.exit(0); + } + + const org = process.env.ORGANIZATION || 'pulseengine'; + const octokit = await getOctokit(); + + if (cmd === 'org' && sub === 'health') return cmdOrgHealth(octokit, org); + if (cmd === 'org' && sub === 'repos') return cmdOrgRepos(octokit, org); + if (cmd === 'repo' && sub === 'check') return cmdRepoCheck(octokit, org, args[2]); + if (cmd === 'sync') return cmdSync(octokit, org); + + console.error(err(`Unknown command: ${cmd} ${sub || ''}`)); + process.exit(1); +} + +main().catch(e => { console.error(err(e.message)); process.exit(1); }); diff --git a/src/app.js b/src/app.js index 26d7753..af1a1b0 100644 --- a/src/app.js +++ b/src/app.js @@ -1,3 +1,4 @@ +import { createDashboardHandler } from './dashboard.js'; import { getConfig } from './config.js'; import { getLogger, setLogger } from './logger.js'; import { configureRepository } from './repository.js'; @@ -454,6 +455,7 @@ function registerApp(app, { getRouter, addHandler } = {}) { applySecurityMiddleware(router); + router.get('/health', (req, res) => { res.status(200).json({ status: 'healthy', @@ -471,6 +473,7 @@ function registerApp(app, { getRouter, addHandler } = {}) { }); } else if (addHandler) { addHandler(createCustomRoutesHandler()); + addHandler(createDashboardHandler()); } app.onError((error) => { diff --git a/src/dashboard.js b/src/dashboard.js new file mode 100644 index 0000000..3d12ecf --- /dev/null +++ b/src/dashboard.js @@ -0,0 +1,392 @@ +import { getConfig } from './config.js'; +import { getLogger } from './logger.js'; +import { analyzeOrganizationRepositories, synchronizeAllRepositories } from './organization.js'; +import { createAppAuth } from '@octokit/auth-app'; +import { Octokit } from '@octokit/rest'; + +// ── Cache ──────────────────────────────────────────────────────────── +const cache = { data: null, timestamp: 0 }; +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +function isCacheValid() { + return cache.data && (Date.now() - cache.timestamp) < CACHE_TTL; +} + +export function invalidateCache() { + cache.data = null; + cache.timestamp = 0; +} + +// ── Octokit Factory ────────────────────────────────────────────────── +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 { 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 } + }); + + return _octokit; +} + +// ── Data Fetching ──────────────────────────────────────────────────── +async function fetchOrgData() { + if (isCacheValid()) return cache.data; + + const octokit = await getOctokit(); + const config = getConfig(); + const org = config?.organization || 'pulseengine'; + + const analysis = await analyzeOrganizationRepositories(octokit, org); + if (!analysis.success) throw new Error(analysis.error); + + const repos = analysis.repositories; + + // Enrich with CI status + for (const repo of repos) { + try { + 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' }; + } + } + + const result = { + org, + timestamp: new Date().toISOString(), + repos: repos.sort((a, b) => a.name.localeCompare(b.name)), + summary: buildSummary(repos) + }; + + cache.data = result; + cache.timestamp = Date.now(); + return result; +} + +function buildSummary(repos) { + const total = repos.length; + 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 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; + }).length; + + const issues = []; + repos.forEach(r => { + if (!r.configurations?.branch_protection?.exists) issues.push(`${r.name}: 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`); + } + }); + + return { total, withProtection, withCI, withSigned, correctMerge, issues }; +} + +// ── HTML Rendering ─────────────────────────────────────────────────── +function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } + +function badge(cls, label) { return `${esc(label)}`; } + +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 [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('+')); +} + +function timeAgo(dateStr) { + if (!dateStr) return ''; + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +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
+
`; +} + +function renderReposPartial(data) { + const rows = data.repos.map(r => { + const bp = r.configurations?.branch_protection || {}; + const ms = r.configurations?.merge_settings || {}; + 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))} + `; + }).join(''); + + return ` + + + + + ${rows} +
RepositoryProtectedEnforce AdminReviewsCIMergeUpdated
`; +} + +function renderDashboardPage() { + return ` + + + + +Temper Dashboard + + + + + +
+
+

temper

+
+ + governance dashboard +
+
+
+
+

Actions

+
+ + +
+
+
+
+

Repositories

+
+
+
+ + +`; +} + +// ── Handler for Probot's addHandler pattern ────────────────────────── +// Returns async (req, res) => boolean, matching Probot v14's handler chain + +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)); +} + +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 + 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
'); + } + 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
'); + } + 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 }); + } + 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 }); + } + 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 }); + } + 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 }); + } + return true; + } + + return false; + }; +}