feat: deliver policy-driven Git Validator 2.0
This commit is contained in:
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
|
||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
schemaVersion: 12,
|
||||
schemaVersion: 13,
|
||||
setupComplete: false,
|
||||
appearance: 'dark',
|
||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_CONFIG = {
|
||||
deploymentProfiles: {},
|
||||
deploymentStates: {},
|
||||
inventoryReviewDecisions: {},
|
||||
gitValidator: { policies: {}, suppressions: {}, trends: {} },
|
||||
favorites: [],
|
||||
updates: {
|
||||
owner: 'Jens',
|
||||
@@ -67,6 +68,11 @@ class ConfigStore {
|
||||
workspaceRoots: uniqueStrings(source.workspaceRoots),
|
||||
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
|
||||
inventoryReviewDecisions: source.inventoryReviewDecisions && typeof source.inventoryReviewDecisions === 'object' ? structuredClone(source.inventoryReviewDecisions) : {},
|
||||
gitValidator: {
|
||||
policies: source.gitValidator?.policies && typeof source.gitValidator.policies === 'object' ? structuredClone(source.gitValidator.policies) : {},
|
||||
suppressions: source.gitValidator?.suppressions && typeof source.gitValidator.suppressions === 'object' ? structuredClone(source.gitValidator.suppressions) : {},
|
||||
trends: source.gitValidator?.trends && typeof source.gitValidator.trends === 'object' ? structuredClone(source.gitValidator.trends) : {}
|
||||
},
|
||||
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object'
|
||||
? Object.fromEntries(Object.entries(source.deploymentProfiles).map(([key, profiles]) => [key, (Array.isArray(profiles) ? profiles : []).map((profile) => {
|
||||
if (!profile || typeof profile !== 'object' || profile.provider !== 'ssh-unraid') return profile;
|
||||
@@ -144,6 +150,36 @@ class ConfigStore {
|
||||
return this.saveQueue;
|
||||
}
|
||||
|
||||
getGitValidatorState(fullName) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
return {
|
||||
policy: structuredClone(this.data.gitValidator.policies[key] || { id: 'standard' }),
|
||||
suppressions: structuredClone(this.data.gitValidator.suppressions[key] || []),
|
||||
trends: structuredClone(this.data.gitValidator.trends[key] || [])
|
||||
};
|
||||
}
|
||||
|
||||
async setGitValidatorPolicy(fullName, policy) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
this.data.gitValidator.policies[key] = structuredClone(policy);
|
||||
await this.save();
|
||||
return this.getGitValidatorState(key);
|
||||
}
|
||||
|
||||
async addGitValidatorSuppression(fullName, suppression) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
this.data.gitValidator.suppressions[key] = [...(this.data.gitValidator.suppressions[key] || []), structuredClone(suppression)].slice(-250);
|
||||
await this.save();
|
||||
return this.getGitValidatorState(key);
|
||||
}
|
||||
|
||||
async appendGitValidatorTrend(fullName, trend) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
this.data.gitValidator.trends[key] = [...(this.data.gitValidator.trends[key] || []), structuredClone(trend)].slice(-100);
|
||||
await this.save();
|
||||
return this.getGitValidatorState(key);
|
||||
}
|
||||
|
||||
async createRecoverySnapshot(reason = 'configuration-change') {
|
||||
await this.saveQueue.catch(() => {});
|
||||
const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change';
|
||||
@@ -602,6 +638,7 @@ class ConfigStore {
|
||||
repositoryMappings: { ...this.data.repositoryMappings },
|
||||
deploymentProfiles: structuredClone(this.data.deploymentProfiles),
|
||||
deploymentStates: structuredClone(this.data.deploymentStates),
|
||||
gitValidator: structuredClone(this.data.gitValidator),
|
||||
favorites: [...this.data.favorites],
|
||||
updates: { ...this.data.updates },
|
||||
servers: this.data.servers.map((server) => this.getPublicServer(server)),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const PROFILE_DEFINITIONS = Object.freeze({
|
||||
minimal: { label: "Minimal", requiredScore: 55, severities: ["error"], allowSuppressions: true, maxSuppressionDays: 180 },
|
||||
standard: { label: "Standard", requiredScore: 70, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 90 },
|
||||
strict: { label: "Strict", requiredScore: 82, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 30 },
|
||||
production: { label: "Production", requiredScore: 90, severities: ["error", "warning"], allowSuppressions: true, maxSuppressionDays: 14 },
|
||||
});
|
||||
|
||||
function normalizePolicy(policy = {}) {
|
||||
const id = String(policy.id || policy.profile || "standard").toLowerCase();
|
||||
const base = PROFILE_DEFINITIONS[id] || PROFILE_DEFINITIONS.standard;
|
||||
const custom = id === "organization" ? policy : {};
|
||||
return {
|
||||
id,
|
||||
label: custom.label || base.label || "Organization custom",
|
||||
requiredScore: Math.min(100, Math.max(0, Number(custom.requiredScore ?? base.requiredScore ?? 80))),
|
||||
enabledChecks: Array.isArray(custom.enabledChecks) ? [...new Set(custom.enabledChecks.map(String))] : null,
|
||||
severityOverrides: custom.severityOverrides && typeof custom.severityOverrides === "object" ? { ...custom.severityOverrides } : {},
|
||||
blockingChecks: [...new Set((custom.blockingChecks || policy.blockingChecks || []).map(String))],
|
||||
allowSuppressions: custom.allowSuppressions ?? base.allowSuppressions ?? true,
|
||||
maxSuppressionDays: Math.max(1, Number(custom.maxSuppressionDays ?? base.maxSuppressionDays ?? 30)),
|
||||
};
|
||||
}
|
||||
|
||||
function validateSuppression(input, policy, now = new Date()) {
|
||||
if (!policy.allowSuppressions) throw new Error("The selected policy does not allow suppressions.");
|
||||
const checkId = String(input?.checkId || "").trim();
|
||||
const reason = String(input?.reason || "").trim();
|
||||
const author = String(input?.author || "").trim();
|
||||
const scope = String(input?.scope || "repository").trim();
|
||||
const evidence = String(input?.evidence || "").trim();
|
||||
const expiresAt = new Date(input?.expiresAt || "");
|
||||
if (!checkId || reason.length < 10 || !author || !evidence) throw new Error("A suppression requires a check ID, author, evidence and a reason of at least 10 characters.");
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= now) throw new Error("A suppression expiry must be in the future.");
|
||||
const maximum = new Date(now.getTime() + policy.maxSuppressionDays * 86_400_000);
|
||||
if (expiresAt > maximum) throw new Error(`This policy permits suppressions for at most ${policy.maxSuppressionDays} days.`);
|
||||
return {
|
||||
id: crypto.randomUUID(), checkId, reason, author,
|
||||
createdAt: now.toISOString(), ticket: String(input.ticket || "").trim() || null,
|
||||
expiresAt: expiresAt.toISOString(), scope, evidence,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPolicy(checks, policyInput, suppressions = [], now = new Date()) {
|
||||
const policy = normalizePolicy(policyInput);
|
||||
const enabled = policy.enabledChecks ? new Set(policy.enabledChecks) : null;
|
||||
const relevant = checks.filter((check) => !enabled || enabled.has(check.id)).map((check) => {
|
||||
const status = policy.severityOverrides[check.id] || check.status;
|
||||
const suppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) > now);
|
||||
const expiredSuppression = suppressions.find((item) => item.checkId === check.id && new Date(item.expiresAt) <= now);
|
||||
return {
|
||||
...check,
|
||||
status,
|
||||
suppressed: Boolean(suppression),
|
||||
suppression: suppression || null,
|
||||
expiredSuppression: expiredSuppression || null,
|
||||
blocking: !suppression && status !== "pass" && (status === "error" || policy.blockingChecks.includes(check.id)),
|
||||
};
|
||||
});
|
||||
return { policy, checks: relevant };
|
||||
}
|
||||
|
||||
function buildTrend(previous, report) {
|
||||
const prior = new Map((previous?.checks || []).map((check) => [check.id, check]));
|
||||
const current = new Map(report.checks.map((check) => [check.id, check]));
|
||||
const active = (check) => check && check.status !== "pass" && !check.suppressed;
|
||||
const newlyFound = [...current.values()].filter((check) => active(check) && !active(prior.get(check.id))).map((check) => check.id);
|
||||
const resolved = [...prior.values()].filter((check) => active(check) && !active(current.get(check.id))).map((check) => check.id);
|
||||
const regressions = [...current.values()].filter((check) => active(check) && prior.get(check.id)?.status === "warning" && check.status === "error").map((check) => check.id);
|
||||
return { score: report.score, categories: report.categories, newlyFound, resolved, regressions, suppressions: report.checks.filter((check) => check.suppressed).map((check) => check.id), checkedAt: report.checkedAt, commitSha: report.commitSha || null, checks: report.checks.map(({ id, status, suppressed }) => ({ id, status, suppressed })) };
|
||||
}
|
||||
|
||||
function exportReport(report, format = "json") {
|
||||
if (format === "json") return { extension: "json", mimeType: "application/json", content: `${JSON.stringify(report, null, 2)}\n` };
|
||||
const rows = report.checks.map((check) => `| ${check.id} | ${check.category} | ${check.status}${check.suppressed ? " (suppressed)" : ""} | ${String(check.detail).replace(/\|/g, "\\|")} |`).join("\n");
|
||||
const markdown = `# Git assurance report — ${report.repository}\n\nPolicy: **${report.policy.label}** · Score: **${report.score}/100** · Commit: \`${report.commitSha || "unknown"}\`\n\n| Check | Category | Status | Evidence |\n|---|---|---|---|\n${rows}\n`;
|
||||
if (format === "markdown") return { extension: "md", mimeType: "text/markdown", content: markdown };
|
||||
if (format !== "html") throw new Error("Unsupported Git Validator export format.");
|
||||
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
||||
const htmlRows = report.checks.map((check) => `<tr><td>${escape(check.id)}</td><td>${escape(check.category)}</td><td>${escape(check.status)}${check.suppressed ? " (suppressed)" : ""}</td><td>${escape(check.detail)}</td></tr>`).join("");
|
||||
return { extension: "html", mimeType: "text/html", content: `<!doctype html><html lang="en"><meta charset="utf-8"><title>Git assurance — ${escape(report.repository)}</title><style>body{font:15px system-ui;max-width:1100px;margin:40px auto;padding:0 24px;color:#172033}table{border-collapse:collapse;width:100%}th,td{padding:10px;border:1px solid #ccd4e0;text-align:left}th{background:#edf2f7}</style><h1>Git assurance — ${escape(report.repository)}</h1><p>Policy: <strong>${escape(report.policy.label)}</strong> · Score: <strong>${report.score}/100</strong> · Commit: <code>${escape(report.commitSha || "unknown")}</code></p><table><thead><tr><th>Check</th><th>Category</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${htmlRows}</tbody></table></html>` };
|
||||
}
|
||||
|
||||
module.exports = { PROFILE_DEFINITIONS, normalizePolicy, validateSuppression, applyPolicy, buildTrend, exportReport };
|
||||
@@ -4,6 +4,7 @@ const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { run } = require("./process-runner.cjs");
|
||||
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
|
||||
const { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.cjs");
|
||||
|
||||
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
|
||||
.env
|
||||
@@ -67,6 +68,7 @@ function result(id, category, title, status, detail, options = {}) {
|
||||
fixAction: options.fixAction || null,
|
||||
safe: options.safe === true,
|
||||
confirmation: options.confirmation || null,
|
||||
evidence: options.evidence || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,10 +86,11 @@ function isSensitiveTrackedPath(filePath) {
|
||||
}
|
||||
|
||||
class GitValidatorService {
|
||||
constructor({ git, gitea, diagnostics }) {
|
||||
constructor({ git, gitea, diagnostics, store }) {
|
||||
this.git = git;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
async config(root, key, { local = true } = {}) {
|
||||
@@ -176,7 +179,7 @@ class GitValidatorService {
|
||||
{ weight: 35 },
|
||||
),
|
||||
);
|
||||
return this.summarize(repository, checks);
|
||||
return this.finalize(repository, checks, null);
|
||||
}
|
||||
|
||||
const root = await this.git.ensureRepository(repository.localPath);
|
||||
@@ -378,7 +381,107 @@ class GitValidatorService {
|
||||
{ weight: 7 },
|
||||
),
|
||||
);
|
||||
return this.summarize(repository, checks);
|
||||
await this.addAssuranceChecks(root, tracked, lowerFiles, checks);
|
||||
return this.finalize(repository, checks, status);
|
||||
}
|
||||
|
||||
async addAssuranceChecks(root, tracked, lowerFiles, checks) {
|
||||
const has = (...patterns) => lowerFiles.some((file) => patterns.some((pattern) => pattern.test(file)));
|
||||
const fileCheck = (id, category, title, patterns, detail, weight = 5) => {
|
||||
const present = has(...patterns);
|
||||
checks.push(result(id, category, title, present ? "pass" : "warning", present ? `${title} is versioned.` : detail, { weight }));
|
||||
};
|
||||
fileCheck("security-policy", "Security", "Security policy", [/(^|\/)security\.md$/], "Add SECURITY.md with supported versions and private disclosure instructions.", 8);
|
||||
fileCheck("codeowners", "Governance", "Code ownership", [/(^|\/)codeowners$/], "Add CODEOWNERS for security-sensitive and release paths.", 6);
|
||||
fileCheck("license", "Governance", "Repository license", [/(^|\/)(license|copying)(\.[^/]+)?$/], "Document the repository license or private-use terms.", 5);
|
||||
fileCheck("changelog", "Release readiness", "Changelog", [/(^|\/)changelog(\.[^/]+)?$/], "Add a changelog that maps releases to user-visible changes.", 7);
|
||||
fileCheck("contributing", "Collaboration", "Contribution guide", [/(^|\/)contributing(\.[^/]+)?$/], "Add contribution, test and review instructions.", 4);
|
||||
fileCheck("issue-templates", "Collaboration", "Issue templates", [/^\.gitea\/issue_template\//, /^\.github\/issue_template\//], "Add structured issue templates.", 3);
|
||||
fileCheck("pull-request-template", "Collaboration", "Pull request template", [/(^|\/)pull_request_template\.md$/], "Add a pull request checklist for tests, risk and rollback.", 4);
|
||||
fileCheck("runtime-pinning", "Reproducibility", "Runtime version pinning", [/(^|\/)(\.nvmrc|\.node-version|\.tool-versions|mise\.toml)$/], "Pin the runtime version used by developers and CI.", 7);
|
||||
fileCheck("build-instructions", "Reproducibility", "Build instructions", [/(^|\/)(readme|building|build)(\.[^/]+)?$/], "Document a clean, reproducible build command.", 6);
|
||||
|
||||
const generated = tracked.filter((file) => /(^|\/)(dist|build|coverage|\.cache)\//i.test(file));
|
||||
checks.push(result("generated-artifacts", "Performance and hygiene", "Generated output is not tracked", generated.length ? "warning" : "pass", generated.length ? `${generated.length} generated-path file(s) are tracked; review ${generated.slice(0, 5).join(", ")}.` : "No common generated output directories are tracked.", { weight: 8, evidence: generated.slice(0, 20) }));
|
||||
const executables = tracked.filter((file) => /\.(exe|dll|msi|scr|com|bat|cmd|ps1)$/i.test(file));
|
||||
checks.push(result("executable-artifacts", "Security", "Executable artifacts are intentional", executables.length ? "warning" : "pass", executables.length ? `Review executable content: ${executables.slice(0, 8).join(", ")}.` : "No executable-shaped artifacts are tracked.", { weight: 8, evidence: executables.slice(0, 20) }));
|
||||
|
||||
const workflowFiles = tracked.filter((file) => /^\.(gitea|github)\/workflows\/[^/]+\.ya?ml$/i.test(file));
|
||||
const workflowText = (await Promise.all(workflowFiles.slice(0, 40).map((file) => fs.readFile(path.join(root, file), "utf8").catch(() => "")))).join("\n");
|
||||
const unpinned = [...workflowText.matchAll(/uses:\s*[^\s@]+@([^\s#]+)/g)].map((match) => match[1]).filter((ref) => !/^[0-9a-f]{40}$/i.test(ref));
|
||||
checks.push(result("pinned-actions", "Security", "External CI actions are commit-pinned", unpinned.length ? "warning" : "pass", unpinned.length ? `${unpinned.length} action reference(s) use mutable tags or branches.` : "External actions are commit-pinned or no external actions are used.", { weight: 9, evidence: unpinned.slice(0, 20) }));
|
||||
const broadPermissions = /permissions:\s*(write-all|write)/i.test(workflowText) || /contents:\s*write/i.test(workflowText);
|
||||
checks.push(result("workflow-permissions", "Security", "Workflow permissions use least privilege", broadPermissions ? "error" : "pass", broadPermissions ? "A workflow requests broad write permissions; scope permissions per job and capability." : "No broad workflow write permission was detected.", { weight: 12 }));
|
||||
|
||||
const [commitSignature, tagSignature, recentSubjects] = await Promise.all([
|
||||
run("git", ["log", "-1", "--format=%G?"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => "N"),
|
||||
run("git", ["tag", "--points-at", "HEAD", "--format=%(contents:signature)"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => ""),
|
||||
run("git", ["log", "-20", "--format=%s"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim().split(/\r?\n/).filter(Boolean)).catch(() => []),
|
||||
]);
|
||||
checks.push(result("signed-commits", "Governance", "Latest commit is signed", /[GUYX]/.test(commitSignature) ? "pass" : "warning", /[GUYX]/.test(commitSignature) ? "Git reports a cryptographic signature on HEAD." : "HEAD has no verifiable Git signature.", { weight: 6 }));
|
||||
checks.push(result("signed-tags", "Governance", "Release tags are signed", tagSignature ? "pass" : "warning", tagSignature ? "HEAD has a signed tag." : "HEAD has no signed release tag.", { weight: 5 }));
|
||||
const conventional = recentSubjects.length > 0 && recentSubjects.every((subject) => /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s.+/i.test(subject));
|
||||
checks.push(result("conventional-commits", "Governance", "Recent commits follow a convention", conventional ? "pass" : "warning", conventional ? `${recentSubjects.length} recent commit subject(s) follow Conventional Commits.` : "Some recent commit subjects do not follow Conventional Commits.", { weight: 4 }));
|
||||
|
||||
const releaseFiles = {
|
||||
"release-checksums": /(^|\/)(sha256sums|checksums?)(\.[^/]+)?$/,
|
||||
"release-provenance": /(^|\/)(provenance|attestation)(\.[^/]+)?$/,
|
||||
"release-sbom": /(^|\/)(sbom)(\.[^/]+)?$/,
|
||||
};
|
||||
for (const [id, pattern] of Object.entries(releaseFiles)) fileCheck(id, "Release readiness", id.replace(/^release-/, "Release "), [pattern], `No ${id.replace(/^release-/, "")} artifact is versioned or generated in the repository.`, 4);
|
||||
checks.push(result("signing-readiness", "Release readiness", "Signing policy is documented", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "pass" : "warning", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "Signing guidance is present." : "Document signing identity, verification and timestamp requirements.", { weight: 6 }));
|
||||
}
|
||||
|
||||
async finalize(repository, checks, status) {
|
||||
const repositoryState = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" }, suppressions: [], trends: [] };
|
||||
const { policy, checks: governedChecks } = applyPolicy(checks, repositoryState.policy, repositoryState.suppressions);
|
||||
const report = this.summarize(repository, governedChecks);
|
||||
report.policy = policy;
|
||||
report.commitSha = status?.head || status?.branch?.oid || null;
|
||||
report.categories = Object.fromEntries([...new Set(governedChecks.map((check) => check.category))].map((category) => {
|
||||
const categoryChecks = governedChecks.filter((check) => check.category === category);
|
||||
return [category, Math.round(categoryChecks.filter((check) => check.status === "pass" || check.suppressed).length / categoryChecks.length * 100)];
|
||||
}));
|
||||
report.ready = report.score >= policy.requiredScore && !governedChecks.some((check) => check.blocking);
|
||||
report.expiredSuppressions = governedChecks.filter((check) => check.expiredSuppression).map((check) => check.id);
|
||||
report.trend = buildTrend(repositoryState.trends.at(-1), report);
|
||||
if (this.store?.appendGitValidatorTrend) await this.store.appendGitValidatorTrend(repository.fullName, report.trend);
|
||||
return report;
|
||||
}
|
||||
|
||||
async setPolicy(repository, policyInput) {
|
||||
const policy = normalizePolicy(policyInput);
|
||||
if (!this.store?.setGitValidatorPolicy) throw new Error("Git Validator policy persistence is unavailable.");
|
||||
await this.store.setGitValidatorPolicy(repository.fullName, policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
async suppress(repository, input) {
|
||||
const state = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" } };
|
||||
const suppression = validateSuppression(input, normalizePolicy(state.policy));
|
||||
await this.store.addGitValidatorSuppression(repository.fullName, suppression);
|
||||
return suppression;
|
||||
}
|
||||
|
||||
export(report, format) { return exportReport(report, format); }
|
||||
|
||||
async previewRepair(repository, check) {
|
||||
if (!check?.fixAction) throw new Error("This validator check has no repair action.");
|
||||
const root = repository.localPath ? await this.git.ensureRepository(repository.localPath) : null;
|
||||
const fileDefinitions = {
|
||||
"add-gitignore": [".gitignore", RECOMMENDED_GITIGNORE],
|
||||
"add-gitattributes": [".gitattributes", RECOMMENDED_GITATTRIBUTES],
|
||||
"add-editorconfig": [".editorconfig", RECOMMENDED_EDITORCONFIG],
|
||||
};
|
||||
if (fileDefinitions[check.fixAction]) {
|
||||
const [name, content] = fileDefinitions[check.fixAction];
|
||||
if (await fs.stat(path.join(root, name)).catch(() => null)) throw new Error(`${name} already exists; rescan before repairing.`);
|
||||
return { checkId: check.id, action: check.fixAction, files: [name], diff: `diff --git a/${name} b/${name}\nnew file mode 100644\n--- /dev/null\n+++ b/${name}\n${content.split("\n").filter((line, index, lines) => index < lines.length - 1).map((line) => `+${line}`).join("\n")}\n`, remoteMutation: false };
|
||||
}
|
||||
if (check.fixAction === "configure-local-safety") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ fetch.prune = true\n+ pull.ff = only\n+ rebase.autoStash = true\n", remoteMutation: false };
|
||||
if (check.fixAction === "align-origin") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: `- origin = current\n+ origin = ${repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl}\n`, remoteMutation: false };
|
||||
if (check.fixAction === "protect-default-branch") return { checkId: check.id, action: check.fixAction, files: [], diff: `Gitea policy change:\n+ protect ${repository.defaultBranch || "main"}\n+ block force pushes\n+ require pull request review\n`, remoteMutation: true };
|
||||
throw new Error("Unsupported Git Validator repair action.");
|
||||
}
|
||||
|
||||
summarize(repository, checks) {
|
||||
|
||||
@@ -359,6 +359,27 @@ function registerRepositoryIpc({
|
||||
});
|
||||
return report;
|
||||
});
|
||||
register("git-validator:set-policy", async ({ fullName, policy }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const saved = await gitValidator.setPolicy(repository, policy);
|
||||
await audit.append("git-validator.policy.changed", { repository: repository.fullName, policy: saved.id });
|
||||
return saved;
|
||||
});
|
||||
register("git-validator:suppress", async ({ fullName, suppression }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const saved = await gitValidator.suppress(repository, suppression);
|
||||
await audit.append("git-validator.finding.suppressed", { repository: repository.fullName, checkId: saved.checkId, expiresAt: saved.expiresAt, ticket: saved.ticket });
|
||||
return saved;
|
||||
});
|
||||
register("git-validator:preview-repair", async ({ fullName, check }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
return gitValidator.previewRepair(repository, check);
|
||||
});
|
||||
register("git-validator:export", async ({ fullName, format = "json" }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const report = await gitValidator.scan(repository);
|
||||
return gitValidator.export(report, format);
|
||||
});
|
||||
register("git-validator:repair", async ({ fullName, check }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const allowed = new Set([
|
||||
|
||||
@@ -106,7 +106,7 @@ function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIden
|
||||
const deployKey = await this.gitea.ensureReadOnlyDeployKey({
|
||||
owner,
|
||||
repo,
|
||||
title: `ForgeFlow · ${server.name} · read-only`,
|
||||
title: `ForgeFlow · ${server.name} · read-only`,
|
||||
publicKey,
|
||||
});
|
||||
const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`;
|
||||
|
||||
@@ -199,7 +199,7 @@ function createUnraidInventoryMethods({
|
||||
const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`;
|
||||
return {
|
||||
id: profileId,
|
||||
name: `${server.name} · ${workload.displayName}`,
|
||||
name: `${server.name} · ${workload.displayName}`,
|
||||
environment: "production",
|
||||
provider: "ssh-unraid",
|
||||
branch: workload.metadata?.branch || repository.defaultBranch || "main",
|
||||
|
||||
@@ -288,7 +288,7 @@ function createUnraidPreflightMethods({
|
||||
connectionCapabilities = connection.capabilities || {};
|
||||
checks.push({
|
||||
id: "ssh",
|
||||
label: "Desktop → Unraid SSH",
|
||||
label: "Desktop → Unraid SSH",
|
||||
status: "pass",
|
||||
detail: `${server.username}@${server.host}:${server.port}`,
|
||||
});
|
||||
@@ -327,7 +327,7 @@ function createUnraidPreflightMethods({
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: "ssh",
|
||||
label: "Desktop → Unraid SSH",
|
||||
label: "Desktop → Unraid SSH",
|
||||
status: "fail",
|
||||
detail: error.message,
|
||||
});
|
||||
@@ -349,7 +349,7 @@ function createUnraidPreflightMethods({
|
||||
const access = await this.probeServerGitAccess({ repository, profile, server });
|
||||
checks.push({
|
||||
id: "server-git-access",
|
||||
label: "Unraid → Gitea read access",
|
||||
label: "Unraid → Gitea read access",
|
||||
status: access.ready ? "pass" : "fail",
|
||||
detail: access.ready
|
||||
? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.`
|
||||
@@ -359,7 +359,7 @@ function createUnraidPreflightMethods({
|
||||
});
|
||||
} else checks.push({
|
||||
id: "transfer-path",
|
||||
label: "Desktop → Unraid transfer",
|
||||
label: "Desktop → Unraid transfer",
|
||||
status: "pass",
|
||||
detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.",
|
||||
});
|
||||
@@ -387,7 +387,7 @@ function createUnraidPreflightMethods({
|
||||
id: `write-path:${target.id}`,
|
||||
label: target.label,
|
||||
status: "fail",
|
||||
detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`,
|
||||
detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`,
|
||||
repairAction: "repair-deployment-write-access",
|
||||
repairLabel: "Fix write access",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user