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.withProtection}
Protected
-
-
${s.withSigned}
Signed Commits
-
${s.correctMerge}
Correct Merge
-
-
`;
+ const t = s.total;
+ function card(value, label, cls) {
+ return ``;
+ }
+ 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 `
-
- | Repository | Protected | Enforce Admin |
- Reviews | CI | Merge | Updated |
-
+
+ | Repository | Protected | Signed | CI |
+ Merge | Auto | Dependabot | Labels | Updated |
+
+ ${rows}
+
`;
+}
+
+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 `
+
+ | Repository | Issues |
${rows}
-
`;
+
+ 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
+
-
-
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;
}