diff --git a/package-lock.json b/package-lock.json index 382ae3c..1bd87bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oddkit", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oddkit", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/src/cli.js b/src/cli.js index 4f528aa..bda1605 100644 --- a/src/cli.js +++ b/src/cli.js @@ -210,6 +210,11 @@ export function run() { return; } + // Build author from CLI flags if provided + const author = (options.authorName && options.authorEmail) + ? { name: options.authorName, email: options.authorEmail } + : undefined; + const result = await handleAction({ action: tool.name, input: input || "", @@ -217,6 +222,13 @@ export function run() { mode: options.mode, baseline: options.baseline, repoRoot: options.repo, + files: options.files ? JSON.parse(options.files) : undefined, + message: options.commitMessage, + branch: options.branch, + pr: options.pr, + repo: options.repoTarget, + author, + surface: "cli", }); outputActionResult(tool.name, result, format, quiet); @@ -536,6 +548,11 @@ export function run() { return; } + // Build author from CLI flags if provided + const author = (options.authorName && options.authorEmail) + ? { name: options.authorName, email: options.authorEmail } + : undefined; + const result = await handleAction({ action: tool.name, input: input || "", @@ -543,6 +560,13 @@ export function run() { mode: options.mode, baseline: options.baseline, repoRoot: options.repo, + files: options.files ? JSON.parse(options.files) : undefined, + message: options.commitMessage, + branch: options.branch, + pr: options.pr, + repo: options.repoTarget, + author, + surface: "cli", }); const ok = !isActionError(result); console.log(JSON.stringify(wrapToolJson(tool.name, result, ok))); diff --git a/src/core/actions.js b/src/core/actions.js index 79c74bd..3296a96 100644 --- a/src/core/actions.js +++ b/src/core/actions.js @@ -18,7 +18,13 @@ import { runEncode } from "../tasks/encode.js"; import { buildBM25Index, searchBM25 } from "../search/bm25.js"; import { buildIndex, loadIndex, saveIndex, INDEX_VERSION } from "../index/buildIndex.js"; import { ensureBaselineRepo, getSessionSha } from "../baseline/ensureBaselineRepo.js"; -import { ACTION_NAMES } from "./tool-registry.js"; +import { ALL_ACTION_NAMES } from "./tool-registry.js"; +import { validateFiles } from "../utils/writeValidation.js"; +import { + parseBaselineUrl, getFileSha, writeFile, + getDefaultBranch, getRef, branchExists, createBranch, createPR, + atomicMultiFileCommit, +} from "../utils/githubApi.js"; import { readFileSync, existsSync } from "fs"; import { createRequire } from "module"; import matter from "gray-matter"; @@ -30,7 +36,7 @@ const { version: VERSION } = require("../../package.json"); // Valid actions — derived from the shared tool registry (single source of truth) // ────────────────────────────────────────────────────────────────────────────── -export const VALID_ACTIONS = ACTION_NAMES; +export const VALID_ACTIONS = ALL_ACTION_NAMES; // ────────────────────────────────────────────────────────────────────────────── // State management @@ -478,6 +484,222 @@ export async function handleAction(params) { }; } + case "write": { + // oddkit_write — one action, progressive protection + // Tier 1: Contents API for single file + // Tier 2: Git Data API for multi-file atomic commits + // Tier 3: Branch creation and PR support (layers on top) + const { files, message, pr, repo: providedRepo, author, provenance } = params; + let { branch } = params; + + // --- Input validation --- + if (!files || !Array.isArray(files) || files.length === 0) { + return { + action: "write", + result: { error: "No files provided. Expected array of {path, content} objects." }, + assistant_text: "No files provided. Please provide an array of files with path and content.", + debug: makeDebug(), + }; + } + + if (!message) { + return { + action: "write", + result: { error: "Commit message required." }, + assistant_text: "Commit message is required.", + debug: makeDebug(), + }; + } + + if (pr && !branch) { + branch = `oddkit-write/${Date.now()}`; + } + + // --- Resolve target repo --- + let owner, repoName; + + if (providedRepo) { + const parts = providedRepo.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return { + action: "write", + result: { error: `Invalid repo format: "${providedRepo}". Expected "owner/repo".` }, + assistant_text: `Invalid repo format: "${providedRepo}". Expected "owner/repo".`, + debug: makeDebug(), + }; + } + owner = parts[0]; + repoName = parts[1]; + } else { + const baselineUrl = baseline || process.env.ODDKIT_BASELINE; + if (!baselineUrl) { + return { + action: "write", + result: { error: "No target repo specified. Provide repo param or set ODDKIT_BASELINE." }, + assistant_text: "Write requires an explicit target repo. Provide the repo parameter (owner/repo) or set ODDKIT_BASELINE.", + debug: makeDebug(), + }; + } + try { + const parsed = parseBaselineUrl(baselineUrl); + owner = parsed.owner; + repoName = parsed.repo; + } catch (err) { + return { + action: "write", + result: { error: err.message }, + assistant_text: `Failed to parse baseline URL: ${err.message}. Set ODDKIT_BASELINE or use repo parameter.`, + debug: makeDebug(), + }; + } + } + + // --- Validate files against governance constraints --- + const validation = validateFiles(files); + + // Block writes if any path is unsafe (traversal sequences) + const unsafePaths = validation.results + .filter((r) => r.checks.some((c) => c.name === "path_safe" && !c.passed)) + .map((r) => r.file); + if (unsafePaths.length > 0) { + return { + action: "write", + result: { error: `Unsafe path(s) detected: ${unsafePaths.join(", ")}`, validation }, + assistant_text: `Write blocked: unsafe path detected in ${unsafePaths.join(", ")}. Remove '..' or '~' sequences.`, + debug: makeDebug(), + }; + } + + // --- Build provenance footer --- + // Use structured provenance param when present, fall back to surface from caller context + const surfaceValue = provenance?.surface || params.surface || "mcp"; + const provenanceLines = [`oddkit-surface: ${surfaceValue}`]; + if (provenance?.session_id) { + provenanceLines.push(`oddkit-session: ${provenance.session_id}`); + } + provenanceLines.push(`oddkit-timestamp: ${new Date().toISOString()}`); + const commitMessage = `${message}\n\n---\n${provenanceLines.join("\n")}`; + + // --- Determine author --- + const gitAuthor = author || null; + + try { + // --- Resolve target branch --- + let targetBranch = branch; + let defaultBranch = null; + let status = "committed"; + + if (!targetBranch) { + defaultBranch = await getDefaultBranch(owner, repoName); + targetBranch = defaultBranch; + } else { + const exists = await branchExists(owner, repoName, targetBranch); + if (!exists) { + defaultBranch = await getDefaultBranch(owner, repoName); + const { sha: sourceSha } = await getRef(owner, repoName, defaultBranch); + await createBranch(owner, repoName, targetBranch, sourceSha); + status = "branch_created"; + } + } + + // --- Write files --- + let commitResult; + + if (files.length === 1) { + // Tier 1: Contents API — single file + const file = files[0]; + const sha = await getFileSha(owner, repoName, file.path, targetBranch); + const result = await writeFile( + owner, repoName, file.path, file.content, + commitMessage, targetBranch, sha, gitAuthor, + ); + commitResult = { commit_sha: result.commit_sha, commit_url: result.commit_url }; + } else { + // Tier 2: Git Data API — multi-file atomic commit + commitResult = await atomicMultiFileCommit( + owner, repoName, targetBranch, files, commitMessage, gitAuthor, + ); + } + + // --- Handle PR if requested (Tier 3) --- + let prResult = null; + let prError = null; + // TODO: Orphan prevention (Layer 4) — before creating a new PR, check for + // existing open PRs from oddkit on the same branch or targeting the same files. + // If found, push to the existing branch instead (the PR updates automatically). + // The output interface supports this via pr_updated. Deferred until Layer 4. + if (pr && branch) { + try { + const prOpts = typeof pr === "object" ? pr : {}; + const prTitle = prOpts.title || message; + const prBody = prOpts.body || `Files:\n${files.map((f) => `- ${f.path}`).join("\n")}\n\n---\nWritten via oddkit_write`; + const prDraft = prOpts.draft || false; + const baseBranch = defaultBranch || await getDefaultBranch(owner, repoName); + prResult = await createPR(owner, repoName, prTitle, prBody, branch, baseBranch, prDraft); + status = "pr_opened"; + } catch (prErr) { + prError = prErr.message; + status = "committed_pr_failed"; + } + } + + const filesWritten = files.map((f) => f.path); + const validationWarnings = !validation.passed + ? validation.results.map((r) => r.checks.filter((c) => !c.passed).map((c) => c.name).join(", ")).filter((x) => x).join("; ") + : ""; + + return { + action: "write", + result: { + status, + commit_sha: commitResult.commit_sha, + commit_url: commitResult.commit_url, + branch: targetBranch, + files_written: filesWritten, + pr_url: prResult?.pr_url || undefined, + pr_number: prResult?.pr_number || undefined, + pr_error: prError || undefined, + pr_updated: false, // TODO: set to true when orphan prevention detects existing PR + validation, + }, + assistant_text: `Successfully wrote ${filesWritten.length} file(s) to ${owner}/${repoName} on branch ${targetBranch}. Commit: ${commitResult.commit_url}${prResult ? `\nPR: ${prResult.pr_url}` : ""}${prError ? `\nPR creation failed: ${prError}` : ""}${validationWarnings ? `\n\nValidation warnings: ${validationWarnings}` : ""}`, + debug: makeDebug({ files_count: files.length, tier: files.length === 1 ? 1 : 2, validation_passed: validation.passed }), + }; + + } catch (err) { + // --- Conflict handling --- + if (err.status === 409 && err.conflictData) { + return { + action: "write", + result: { + status: "conflict", + error: err.message, + conflict: err.conflictData, + validation, + }, + assistant_text: `${err.conflictData.guidance || err.message}`, + debug: makeDebug({ files_count: files.length }), + }; + } + + // --- Network failure: preserve content so it's not lost --- + const preserved = err.retryFailed + ? files.map((f) => ({ path: f.path, content: f.content })) + : undefined; + + return { + action: "write", + result: { + error: err.message, + validation, + preserved_content: preserved, + }, + assistant_text: `Write failed: ${err.message}${preserved ? "\n\nFile contents have been preserved in the response so they are not lost." : ""}${!validation.passed ? "\nValidation warnings: " + validation.results.map((r) => r.checks.filter((c) => !c.passed).map((c) => c.name).join(", ")).filter((x) => x).join("; ") : ""}`, + debug: makeDebug({ files_count: files.length }), + }; + } + } + default: return { action: "error", diff --git a/src/core/tool-registry.js b/src/core/tool-registry.js index 131d135..18421c1 100644 --- a/src/core/tool-registry.js +++ b/src/core/tool-registry.js @@ -265,14 +265,85 @@ export const TOOLS = [ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, cliFlags: {}, }, + { + name: "write", + mcpName: "oddkit_write", + description: "Write files to the GitHub repo. Accepts file path(s), content, commit message. Validates against governance constraints. Single-file uses Contents API; multi-file uses Git Data API for atomic commits. Supports branches and PRs optionally.", + inputSchema: { + type: "object", + properties: { + files: { + type: "array", + items: { + type: "object", + properties: { + path: { type: "string", description: "Repo-relative file path (e.g., docs/decisions/D0017.md)" }, + content: { type: "string", description: "File content (UTF-8 text)" }, + encoding: { type: "string", description: "Content encoding. Default: \"utf-8\". Reserved for future: \"base64\" for binary files." }, + }, + required: ["path", "content"], + }, + description: "Array of files to write", + }, + message: { type: "string", description: "Commit message" }, + branch: { type: "string", description: "Optional: target branch. If omitted, writes to default branch. If provided and doesn't exist, creates it from default branch HEAD." }, + pr: { + oneOf: [ + { type: "boolean" }, + { + type: "object", + properties: { + title: { type: "string", description: "PR title. Default: commit message." }, + body: { type: "string", description: "PR body. Default: auto-generated from file list." }, + draft: { type: "boolean", description: "Open as draft PR. Default: false." }, + }, + }, + ], + description: "Optional: if true or object, opens a PR from branch to default branch.", + }, + repo: { type: "string", description: "Optional: GitHub repo (owner/repo). Defaults to baseline repo." }, + author: { + type: "object", + properties: { + name: { type: "string", description: "Git author name." }, + email: { type: "string", description: "Git author email." }, + }, + required: ["name", "email"], + description: "Optional: git author override. Default: authenticated user.", + }, + provenance: { + type: "object", + properties: { + session_id: { type: "string", description: "Session identifier for traceability." }, + surface: { type: "string", description: "Calling surface: \"claude-chat\", \"voice-agent\", \"team-chat\", \"cli\", \"mcp\", etc." }, + }, + description: "Optional: traceability metadata stored in commit message footer.", + }, + }, + required: ["files", "message"], + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + cliFlags: { + files: { flag: "--files ", description: "JSON array of {path, content} objects" }, + commitMessage: { flag: "--commit-message ", description: "Commit message", required: true }, + branch: { flag: "--branch ", description: "Optional branch name" }, + pr: { flag: "--pr", description: "Open PR after commit" }, + repo: { flag: "--repo-target ", description: "Target GitHub repo (owner/repo)" }, + authorName: { flag: "--author-name ", description: "Git author name" }, + authorEmail: { flag: "--author-email ", description: "Git author email" }, + }, + }, ]; // ────────────────────────────────────────────────────────────────────────────── // Derived constants — single source of truth is TOOLS above // ────────────────────────────────────────────────────────────────────────────── -/** Canonical list of action names, derived from TOOLS. */ -export const ACTION_NAMES = TOOLS.map((t) => t.name); +/** All action names — used as the routing allowlist in handleAction. */ +export const ALL_ACTION_NAMES = TOOLS.map((t) => t.name); + +/** Orchestrator-only action names (excludes write — it has its own dedicated MCP tool with correct schema). */ +export const ACTION_NAMES = TOOLS.filter((t) => t.name !== "write").map((t) => t.name); /** Orchestrator tool definition with action enum derived from TOOLS. */ export const ORCHESTRATOR_TOOL = buildOrchestratorTool(ACTION_NAMES); diff --git a/src/mcp/server.js b/src/mcp/server.js index 0751abe..d40083f 100755 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -192,6 +192,14 @@ async function main() { state: args.state, baseline: args.canon_url, include_metadata: args.include_metadata, + files: args.files, + message: args.message, + branch: args.branch, + pr: args.pr, + repo: args.repo, + author: args.author, + provenance: args.provenance, + surface: "mcp", }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], @@ -209,7 +217,14 @@ async function main() { canon_url: args.canon_url, baseline: args.canon_url, include_metadata: args.include_metadata, - // No state for individual tools + files: args.files, + message: args.message, + branch: args.branch, + pr: args.pr, + repo: args.repo, + author: args.author, + provenance: args.provenance, + surface: "mcp", }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/src/utils/githubApi.js b/src/utils/githubApi.js new file mode 100644 index 0000000..5c4fb08 --- /dev/null +++ b/src/utils/githubApi.js @@ -0,0 +1,431 @@ +/** + * GitHub API helper for oddkit_write + * + * Provides GitHub REST API interactions for writing files to repos. + * Three tiers: + * Tier 1: Contents API (single file) + * Tier 2: Git Data API (multi-file atomic commits) + * Tier 3: Branches and PRs (layers on top of Tier 1/2) + * + * Uses Node.js native fetch (available in Node 18+). + */ + +const GITHUB_API_BASE = "https://api.github.com"; + +/** + * Get GitHub token from environment + */ +function getGitHubToken() { + const token = process.env.ODDKIT_GITHUB_TOKEN || process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("GitHub token not configured. Set ODDKIT_GITHUB_TOKEN environment variable."); + } + return token; +} + +/** + * Parse baseline_url to extract owner and repo + * e.g., https://raw.githubusercontent.com/klappy/klappy.dev/main -> { owner: "klappy", repo: "klappy.dev" } + */ +export function parseBaselineUrl(baselineUrl) { + let match; + + // Try raw.githubusercontent.com format + match = baselineUrl.match(/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)/); + if (match) { + return { owner: match[1], repo: match[2] }; + } + + // Try github.com format + match = baselineUrl.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/); + if (match) { + return { owner: match[1], repo: match[2] }; + } + + throw new Error(`Could not parse owner/repo from baseline_url: ${baselineUrl}`); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Core request with retry +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Check if an error is a network error (not an HTTP status error). + * Network errors warrant retry; HTTP 4xx/5xx do not. + */ +function isNetworkError(err) { + if (err && err.type === "system") return true; + const msg = err?.message || ""; + return ( + msg.includes("fetch failed") || + msg.includes("ECONNRESET") || + msg.includes("ECONNREFUSED") || + msg.includes("ETIMEDOUT") || + msg.includes("ENOTFOUND") || + msg.includes("network") || + msg.includes("socket hang up") + ); +} + +/** + * Make authenticated GitHub API request with one retry on network errors. + * + * - Retries once on network failures (not on 4xx/5xx). + * - On 409 Conflict, throws a ConflictError with response details. + * - On auth/permission errors, throws descriptive messages. + */ +async function githubRequest(endpoint, options = {}) { + const token = getGitHubToken(); + const url = `${GITHUB_API_BASE}${endpoint}`; + + const fetchOptions = { + ...options, + headers: { + "Authorization": `Bearer ${token}`, + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + ...options.headers, + }, + }; + + async function attempt() { + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + + if (response.status === 401) { + throw new Error("GitHub token is invalid or expired. Check ODDKIT_GITHUB_TOKEN."); + } + if (response.status === 403) { + throw new Error(`Token doesn't have write access. The token needs \`repo\` scope. GitHub says: ${errorBody.message || ""}`); + } + if (response.status === 409) { + const err = new Error(`Conflict: ${errorBody.message || "resource was modified"}`); + err.status = 409; + err.body = errorBody; + throw err; + } + + throw new Error(`GitHub API error: ${response.status} ${response.statusText} - ${errorBody.message || ""}`); + } + + if (response.status === 204) return null; + return response.json(); + } + + // First attempt + try { + return await attempt(); + } catch (err) { + // Only retry on network errors, not HTTP status errors + if (isNetworkError(err)) { + try { + return await attempt(); + } catch (retryErr) { + // Final failure — attach the file contents context for callers + retryErr.retryFailed = true; + throw retryErr; + } + } + throw err; + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Tier 1: Contents API (single file) +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Get current file SHA (required for updates via Contents API). + * Returns null if the file doesn't exist. + */ +export async function getFileSha(owner, repo, path, branch = null) { + const endpoint = `/repos/${owner}/${repo}/contents/${path}${branch ? `?ref=${branch}` : ""}`; + try { + const data = await githubRequest(endpoint); + return data.sha; + } catch (err) { + if (err.message.includes("404")) return null; + throw err; + } +} + +/** + * Get current file content and SHA (for conflict resolution). + * Returns { sha, content } or null if file doesn't exist. + */ +export async function getFileContent(owner, repo, path, branch = null) { + const endpoint = `/repos/${owner}/${repo}/contents/${path}${branch ? `?ref=${branch}` : ""}`; + try { + const data = await githubRequest(endpoint); + return { + sha: data.sha, + content: Buffer.from(data.content, "base64").toString("utf-8"), + }; + } catch (err) { + if (err.message.includes("404")) return null; + throw err; + } +} + +/** + * Write a single file via Contents API (Tier 1). + * Supports optional author override. + */ +export async function writeFile(owner, repo, path, content, message, branch = null, sha = null, author = null) { + const encodedContent = Buffer.from(content, "utf-8").toString("base64"); + + const body = { message, content: encodedContent }; + if (sha) body.sha = sha; + if (branch) body.branch = branch; + if (author) { + body.committer = { name: author.name, email: author.email }; + body.author = { name: author.name, email: author.email }; + } + + const endpoint = `/repos/${owner}/${repo}/contents/${path}`; + + try { + const data = await githubRequest(endpoint, { + method: "PUT", + body: JSON.stringify(body), + }); + + return { + commit_sha: data.commit.sha, + commit_url: data.commit.html_url, + file_path: data.content.path, + sha: data.content.sha, + }; + } catch (err) { + // Handle 409 Conflict — file was modified since last read + if (err.status === 409) { + const current = await getFileContent(owner, repo, path, branch); + const conflictErr = new Error("Merge conflict: the file was modified since you last read it."); + conflictErr.status = 409; + conflictErr.conflictData = { + path, + current_sha: current?.sha || null, + current_content: current?.content || null, + your_content: content, + guidance: "The file was modified since you last read it. Here's the current version — want to merge your changes?", + }; + throw conflictErr; + } + throw err; + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Tier 2: Git Data API (multi-file atomic commits) +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Get a git ref (branch HEAD SHA). + * Returns { sha, url } for the ref object. + */ +export async function getRef(owner, repo, branch) { + const data = await githubRequest(`/repos/${owner}/${repo}/git/ref/heads/${branch}`); + return { + sha: data.object.sha, + url: data.object.url, + }; +} + +/** + * Get a commit's tree SHA. + */ +export async function getCommitTree(owner, repo, commitSha) { + const data = await githubRequest(`/repos/${owner}/${repo}/git/commits/${commitSha}`); + return data.tree.sha; +} + +/** + * Create a blob for a file. + */ +export async function createBlob(owner, repo, content, encoding = "utf-8") { + const data = await githubRequest(`/repos/${owner}/${repo}/git/blobs`, { + method: "POST", + body: JSON.stringify({ content, encoding }), + }); + return data.sha; +} + +/** + * Create a tree with multiple file entries. + * @param {string} baseTreeSha - The base tree to build on + * @param {Array<{path: string, blobSha: string}>} entries - File entries + */ +export async function createTree(owner, repo, baseTreeSha, entries) { + const tree = entries.map((e) => ({ + path: e.path, + mode: "100644", // regular file + type: "blob", + sha: e.blobSha, + })); + + const data = await githubRequest(`/repos/${owner}/${repo}/git/trees`, { + method: "POST", + body: JSON.stringify({ base_tree: baseTreeSha, tree }), + }); + return data.sha; +} + +/** + * Create a commit. + * @param {Object} opts + * @param {string} opts.treeSha - Tree SHA to commit + * @param {string[]} opts.parentShas - Parent commit SHAs + * @param {string} opts.message - Commit message + * @param {Object} [opts.author] - Optional author { name, email } + */ +export async function createCommit(owner, repo, { treeSha, parentShas, message, author }) { + const body = { + message, + tree: treeSha, + parents: parentShas, + }; + if (author) { + body.author = { name: author.name, email: author.email }; + body.committer = { name: author.name, email: author.email }; + } + + const data = await githubRequest(`/repos/${owner}/${repo}/git/commits`, { + method: "POST", + body: JSON.stringify(body), + }); + return { + sha: data.sha, + url: data.html_url, + }; +} + +/** + * Update a branch ref to point to a new commit. + * Handles 409 conflict (branch was updated concurrently). + */ +export async function updateRef(owner, repo, branch, commitSha) { + try { + await githubRequest(`/repos/${owner}/${repo}/git/refs/heads/${branch}`, { + method: "PATCH", + body: JSON.stringify({ sha: commitSha, force: false }), + }); + } catch (err) { + if (err.status === 409) { + const conflictErr = new Error("Merge conflict: the branch was updated since the commit was created."); + conflictErr.status = 409; + conflictErr.conflictData = { + branch, + attempted_sha: commitSha, + guidance: "The branch was updated concurrently. Fetch the latest and retry.", + }; + throw conflictErr; + } + throw err; + } +} + +/** + * Perform an atomic multi-file commit via the Git Data API. + * + * Flow: + * 1. GET ref → commit SHA → tree SHA + * 2. Create blobs for each file + * 3. Create new tree with all blobs + * 4. Create commit pointing to new tree + * 5. Update branch ref + * + * @param {string} owner + * @param {string} repo + * @param {string} branch + * @param {Array<{path: string, content: string}>} files + * @param {string} message - Commit message + * @param {Object} [author] - Optional { name, email } + * @returns {{ commit_sha, commit_url }} + */ +export async function atomicMultiFileCommit(owner, repo, branch, files, message, author = null) { + // 1. Get current branch ref and tree + const ref = await getRef(owner, repo, branch); + const treeSha = await getCommitTree(owner, repo, ref.sha); + + // 2. Create blobs for all files + const entries = []; + for (const file of files) { + const blobSha = await createBlob(owner, repo, file.content); + entries.push({ path: file.path, blobSha }); + } + + // 3. Create new tree + const newTreeSha = await createTree(owner, repo, treeSha, entries); + + // 4. Create commit + const commit = await createCommit(owner, repo, { + treeSha: newTreeSha, + parentShas: [ref.sha], + message, + author, + }); + + // 5. Update ref + await updateRef(owner, repo, branch, commit.sha); + + return { + commit_sha: commit.sha, + commit_url: commit.url, + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Tier 3: Branches and PRs +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Get the default branch name for a repo. + */ +export async function getDefaultBranch(owner, repo) { + const data = await githubRequest(`/repos/${owner}/${repo}`); + return data.default_branch; +} + +/** + * Check if a branch exists. + */ +export async function branchExists(owner, repo, branch) { + try { + await githubRequest(`/repos/${owner}/${repo}/branches/${branch}`); + return true; + } catch (err) { + if (err.message.includes("404")) return false; + throw err; + } +} + +/** + * Create a new branch from a ref. + */ +export async function createBranch(owner, repo, branchName, sourceSha) { + await githubRequest(`/repos/${owner}/${repo}/git/refs`, { + method: "POST", + body: JSON.stringify({ + ref: `refs/heads/${branchName}`, + sha: sourceSha, + }), + }); +} + +/** + * Open a pull request. + */ +export async function createPR(owner, repo, title, body, head, base = "main", draft = false) { + const data = await githubRequest(`/repos/${owner}/${repo}/pulls`, { + method: "POST", + body: JSON.stringify({ title, body, head, base, draft }), + }); + + return { + pr_url: data.html_url, + pr_number: data.number, + }; +} diff --git a/src/utils/writeValidation.js b/src/utils/writeValidation.js new file mode 100644 index 0000000..3e21e9d --- /dev/null +++ b/src/utils/writeValidation.js @@ -0,0 +1,142 @@ +/** + * Governance validation for oddkit_write + * + * Validates content against ODD/Canon/Docs standards before writing. + * Validation is informational — does not block writes. + * + * Checks (for canon/, odd/, docs/ files): + * - Frontmatter present + * - Required fields: title, uri, audience, tier, tags, epoch + * - Blockquote present (> line after title) + * - Summary section (## Summary heading) + * - Header quality (no generic headers) + * + * Checks (all files): + * - UTF-8 valid + * - Path valid (no traversal, no absolute paths) + */ + +const GENERIC_HEADERS = [ + "background", + "details", + "information", + "overview", + "introduction", + "misc", + "miscellaneous", + "other", + "notes", + "general", +]; + +/** + * Validate a file's content against governance constraints + * @param {string} path - File path + * @param {string} content - File content + * @returns {Object} Validation results with checks array + */ +export function validateFile(path, content) { + const checks = []; + + // Path safety — blocks writes with traversal sequences or suspicious characters + const isAbsolute = path.startsWith("/"); + const hasTraversal = path.includes("..") || path.includes("~"); + checks.push({ + name: "path_safe", + passed: !hasTraversal && !isAbsolute, + message: hasTraversal + ? "Path contains traversal or suspicious sequences (.. or ~)" + : isAbsolute + ? "Path must be repo-relative, not absolute" + : null, + }); + + // UTF-8 validity — detect unpaired surrogates (valid in JS strings but not in UTF-8) + const hasLoneSurrogates = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? !frontmatter.includes(`${field}:`)); + + checks.push({ + name: "frontmatter_required_fields", + passed: missingFields.length === 0, + message: missingFields.length > 0 + ? `Missing required frontmatter fields: ${missingFields.join(", ")}` + : null, + }); + } + } + + // Blockquote present (> line after title) + // Look for a ">" line in the body (after frontmatter) + const bodyAfterFrontmatter = content.replace(/^---[\s\S]*?---/, "").trim(); + const hasBlockquote = /^>/m.test(bodyAfterFrontmatter); + checks.push({ + name: "blockquote_present", + passed: hasBlockquote, + message: hasBlockquote ? null : "No blockquote (>) found after title. Add a summary line starting with >.", + }); + + // Summary section + const hasSummary = /^## Summary/m.test(content); + checks.push({ + name: "summary_section", + passed: hasSummary, + message: hasSummary ? null : "No ## Summary section found. Recommended for governance docs.", + }); + + // Header quality — flag generic headers + const headers = content.match(/^#{1,6}\s+(.+)$/gm) || []; + const genericFound = []; + for (const header of headers) { + const headerText = header.replace(/^#{1,6}\s+/, "").trim().toLowerCase(); + if (GENERIC_HEADERS.includes(headerText)) { + genericFound.push(header.trim()); + } + } + checks.push({ + name: "header_quality", + passed: genericFound.length === 0, + message: genericFound.length > 0 + ? `Generic headers found: ${genericFound.join(", ")}. Use descriptive headers instead.` + : null, + }); + } + + return { file: path, checks }; +} + +/** + * Validate multiple files + * @param {Array<{path: string, content: string}>} files + * @returns {{ passed: boolean, results: Array }} + */ +export function validateFiles(files) { + const results = files.map((f) => validateFile(f.path, f.content)); + const allPassed = results.every((r) => r.checks.every((c) => c.passed)); + return { passed: allPassed, results }; +}