diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7d89288f7..fcfcff3a8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `omp commit` command to generate conventional commits with changelog updates + ## [8.0.0] - 2026-01-23 ### Added diff --git a/packages/coding-agent/src/commit/analysis/conventional.ts b/packages/coding-agent/src/commit/analysis/conventional.ts new file mode 100644 index 000000000..e9f6e3ddf --- /dev/null +++ b/packages/coding-agent/src/commit/analysis/conventional.ts @@ -0,0 +1,165 @@ +import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; +import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { Type } from "@sinclair/typebox"; +import analysisSystemPrompt from "$c/commit/prompts/analysis-system.md" with { type: "text" }; +import analysisUserPrompt from "$c/commit/prompts/analysis-user.md" with { type: "text" }; +import type { ChangelogCategory, ConventionalAnalysis, ConventionalDetail } from "$c/commit/types"; +import { renderPromptTemplate } from "$c/config/prompt-templates"; + +const ConventionalAnalysisTool = { + name: "create_conventional_analysis", + description: "Analyze a diff and return conventional commit classification.", + parameters: Type.Object({ + type: Type.Union([ + Type.Literal("feat"), + Type.Literal("fix"), + Type.Literal("refactor"), + Type.Literal("docs"), + Type.Literal("test"), + Type.Literal("chore"), + Type.Literal("style"), + Type.Literal("perf"), + Type.Literal("build"), + Type.Literal("ci"), + Type.Literal("revert"), + ]), + scope: Type.Union([Type.String(), Type.Null()]), + details: Type.Array( + Type.Object({ + text: Type.String(), + changelog_category: Type.Optional( + Type.Union([ + Type.Literal("Added"), + Type.Literal("Changed"), + Type.Literal("Fixed"), + Type.Literal("Deprecated"), + Type.Literal("Removed"), + Type.Literal("Security"), + Type.Literal("Breaking Changes"), + ]), + ), + user_visible: Type.Optional(Type.Boolean()), + }), + ), + issue_refs: Type.Array(Type.String()), + }), +}; + +export interface ConventionalAnalysisInput { + model: Model; + apiKey: string; + contextFiles?: Array<{ path: string; content: string }>; + userContext?: string; + typesDescription?: string; + recentCommits?: string[]; + scopeCandidates: string; + stat: string; + diff: string; +} + +/** + * Generate conventional analysis data from a diff and metadata. + */ +export async function generateConventionalAnalysis({ + model, + apiKey, + contextFiles, + userContext, + typesDescription, + recentCommits, + scopeCandidates, + stat, + diff, +}: ConventionalAnalysisInput): Promise { + const prompt = renderPromptTemplate(analysisUserPrompt, { + context_files: contextFiles && contextFiles.length > 0 ? contextFiles : undefined, + user_context: userContext, + types_description: typesDescription, + recent_commits: recentCommits?.join("\n"), + scope_candidates: scopeCandidates, + stat, + diff, + }); + + const response = await completeSimple( + model, + { + systemPrompt: renderPromptTemplate(analysisSystemPrompt), + messages: [{ role: "user", content: prompt, timestamp: Date.now() }], + tools: [ConventionalAnalysisTool], + }, + { apiKey, maxTokens: 2400 }, + ); + + return parseAnalysisFromResponse(response); +} + +function parseAnalysisFromResponse(message: AssistantMessage): ConventionalAnalysis { + const toolCall = extractToolCall(message, "create_conventional_analysis"); + if (toolCall) { + const parsed = validateToolCall([ConventionalAnalysisTool], toolCall) as { + type: ConventionalAnalysis["type"]; + scope: string | null; + details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; + issue_refs: string[]; + }; + return normalizeAnalysis(parsed); + } + + const text = extractTextContent(message); + const parsed = parseJsonPayload(text) as { + type: ConventionalAnalysis["type"]; + scope: string | null; + details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; + issue_refs: string[]; + }; + return normalizeAnalysis(parsed); +} + +function normalizeAnalysis(parsed: { + type: ConventionalAnalysis["type"]; + scope: string | null; + details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; + issue_refs: string[]; +}): ConventionalAnalysis { + const details: ConventionalDetail[] = parsed.details.map((detail) => ({ + text: detail.text.trim(), + changelogCategory: detail.user_visible ? detail.changelog_category : undefined, + userVisible: detail.user_visible ?? false, + })); + return { + type: parsed.type, + scope: parsed.scope?.trim() || null, + details, + issueRefs: parsed.issue_refs ?? [], + }; +} + +function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined { + for (const content of message.content) { + if (content.type === "toolCall" && content.name === name) { + return content; + } + } + return undefined; +} + +function extractTextContent(message: AssistantMessage): string { + return message.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("") + .trim(); +} + +function parseJsonPayload(text: string): unknown { + const trimmed = text.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return JSON.parse(trimmed) as unknown; + } + const match = trimmed.match(/\{[\s\S]*\}/); + if (!match) { + throw new Error("No JSON payload found in analysis response"); + } + return JSON.parse(match[0]) as unknown; +} diff --git a/packages/coding-agent/src/commit/analysis/index.ts b/packages/coding-agent/src/commit/analysis/index.ts new file mode 100644 index 000000000..1eca1491b --- /dev/null +++ b/packages/coding-agent/src/commit/analysis/index.ts @@ -0,0 +1,4 @@ +export { generateConventionalAnalysis } from "./conventional"; +export { extractScopeCandidates } from "./scope"; +export { generateSummary, stripTypePrefix } from "./summary"; +export { validateAnalysis, validateScope, validateSummary } from "./validation"; diff --git a/packages/coding-agent/src/commit/analysis/scope.ts b/packages/coding-agent/src/commit/analysis/scope.ts new file mode 100644 index 000000000..61c1e2214 --- /dev/null +++ b/packages/coding-agent/src/commit/analysis/scope.ts @@ -0,0 +1,216 @@ +import type { NumstatEntry } from "$c/commit/types"; +import { isExcludedFile } from "$c/commit/utils/exclusions"; + +interface ScopeCandidate { + path: string; + percentage: number; + confidence: number; +} + +const PLACEHOLDER_DIRS = new Set([ + "src", + "lib", + "bin", + "crates", + "benches", + "examples", + "internal", + "pkg", + "include", + "tests", + "test", + "docs", + "packages", + "modules", +]); + +const SKIP_DIRS = new Set(["test", "tests", "benches", "examples", "target", "build", "node_modules", ".github"]); + +export interface ScopeCandidatesResult { + scopeCandidates: string; + isWide: boolean; +} + +export function extractScopeCandidates(numstat: NumstatEntry[]): ScopeCandidatesResult { + const componentLines = new Map(); + const paths: string[] = []; + let totalLines = 0; + + for (const entry of numstat) { + const linesChanged = entry.additions + entry.deletions; + if (linesChanged === 0) continue; + const normalizedPath = normalizePathForScope(entry.path); + if (isExcludedFile(normalizedPath)) continue; + paths.push(normalizedPath); + totalLines += linesChanged; + const components = extractComponentsFromPath(normalizedPath); + for (const component of components) { + if (component.split("/").some((segment) => segment.includes("."))) { + continue; + } + componentLines.set(component, (componentLines.get(component) ?? 0) + linesChanged); + } + } + + if (totalLines === 0) { + return { scopeCandidates: "(none - no measurable changes)", isWide: false }; + } + + const candidates = buildScopeCandidates(componentLines, totalLines); + const isWide = isWideChange(candidates, 0.6); + if (isWide) { + const pattern = analyzeWideChange(paths); + if (pattern) { + return { scopeCandidates: `(cross-cutting: ${pattern})`, isWide: true }; + } + return { scopeCandidates: "(none - multi-component change)", isWide: true }; + } + + const suggestionParts: string[] = []; + for (const candidate of candidates.slice(0, 5)) { + if (candidate.percentage < 10) continue; + const confidenceLabel = candidate.path.includes("/") + ? candidate.percentage > 60 + ? "high confidence" + : "moderate confidence" + : "high confidence"; + suggestionParts.push(`${candidate.path} (${candidate.percentage.toFixed(0)}%, ${confidenceLabel})`); + } + + const scopeCandidates = + suggestionParts.length === 0 + ? "(none - unclear component)" + : `${suggestionParts.join(", ")}\nPrefer 2-segment scopes marked 'high confidence'`; + + return { scopeCandidates, isWide: false }; +} + +function buildScopeCandidates(componentLines: Map, totalLines: number): ScopeCandidate[] { + const candidates: ScopeCandidate[] = []; + for (const [path, lines] of componentLines.entries()) { + if (!path.includes("/") && PLACEHOLDER_DIRS.has(path)) continue; + const root = path.split("/")[0] ?? ""; + if (PLACEHOLDER_DIRS.has(root)) continue; + const percentage = (lines / totalLines) * 100; + const isTwoSegment = path.includes("/"); + const confidence = isTwoSegment ? (percentage > 60 ? percentage * 1.2 : percentage * 0.8) : percentage; + candidates.push({ path, percentage, confidence }); + } + return candidates.sort((a, b) => b.confidence - a.confidence); +} + +function isWideChange(candidates: ScopeCandidate[], threshold: number): boolean { + const top = candidates[0]; + if (!top) return false; + return top.percentage / 100 < threshold; +} + +function extractComponentsFromPath(path: string): string[] { + const segments = path.split("/"); + const meaningful: string[] = []; + + const stripExt = (segment: string): string => { + const index = segment.lastIndexOf("."); + return index > 0 ? segment.slice(0, index) : segment; + }; + + const isFile = (segment: string): boolean => { + return segment.includes(".") && !segment.startsWith(".") && segment.lastIndexOf(".") > 0; + }; + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index] ?? ""; + if (PLACEHOLDER_DIRS.has(segment) && segments.length > index + 1) { + continue; + } + if (isFile(segment)) continue; + if (SKIP_DIRS.has(segment)) continue; + + const stripped = stripExt(segment); + if (stripped && !stripped.startsWith(".")) { + meaningful.push(stripped); + } + } + + const components: string[] = []; + if (meaningful.length > 0) { + components.push(meaningful[0]!); + if (meaningful.length >= 2) { + components.push(`${meaningful[0]}/${meaningful[1]}`); + } + } + + return components; +} + +function normalizePathForScope(path: string): string { + const braceStart = path.indexOf("{"); + if (braceStart !== -1) { + const arrowPos = path.indexOf(" => ", braceStart); + if (arrowPos !== -1) { + const braceEnd = path.indexOf("}", arrowPos); + if (braceEnd !== -1) { + const prefix = path.slice(0, braceStart); + const newName = path.slice(arrowPos + 4, braceEnd).trim(); + return `${prefix}${newName}`; + } + } + } + + if (path.includes(" => ")) { + const parts = path.split(" => "); + return parts[1]?.trim() ?? path.trim(); + } + + return path.trim(); +} + +function analyzeWideChange(paths: string[]): string | null { + if (paths.length === 0) return null; + const total = paths.length; + let mdCount = 0; + let testCount = 0; + let configCount = 0; + let hasCargoToml = false; + let hasPackageJson = false; + let errorKeywords = 0; + let typeKeywords = 0; + + for (const path of paths) { + const lowerPath = path.toLowerCase(); + if (lowerPath.endsWith(".md")) { + mdCount += 1; + } + if (lowerPath.includes("/test") || lowerPath.includes("_test.")) { + testCount += 1; + } + if ( + lowerPath.endsWith(".toml") || + lowerPath.endsWith(".yaml") || + lowerPath.endsWith(".yml") || + lowerPath.endsWith(".json") + ) { + configCount += 1; + } + if (path.includes("Cargo.toml")) { + hasCargoToml = true; + } + if (path.includes("package.json")) { + hasPackageJson = true; + } + if (lowerPath.includes("error") || lowerPath.includes("result") || lowerPath.includes("err")) { + errorKeywords += 1; + } + if (lowerPath.includes("type") || lowerPath.includes("struct") || lowerPath.includes("enum")) { + typeKeywords += 1; + } + } + + if (hasCargoToml || hasPackageJson) return "deps"; + if ((mdCount * 100) / total > 70) return "docs"; + if ((testCount * 100) / total > 60) return "tests"; + if ((errorKeywords * 100) / total > 40) return "error-handling"; + if ((typeKeywords * 100) / total > 40) return "type-refactor"; + if ((configCount * 100) / total > 50) return "config"; + return null; +} diff --git a/packages/coding-agent/src/commit/analysis/summary.ts b/packages/coding-agent/src/commit/analysis/summary.ts new file mode 100644 index 000000000..9f89029b0 --- /dev/null +++ b/packages/coding-agent/src/commit/analysis/summary.ts @@ -0,0 +1,114 @@ +import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; +import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { Type } from "@sinclair/typebox"; +import summarySystemPrompt from "$c/commit/prompts/summary-system.md" with { type: "text" }; +import summaryUserPrompt from "$c/commit/prompts/summary-user.md" with { type: "text" }; +import type { CommitSummary } from "$c/commit/types"; +import { renderPromptTemplate } from "$c/config/prompt-templates"; + +const SummaryTool = { + name: "create_commit_summary", + description: "Generate the summary line for a conventional commit message.", + parameters: Type.Object({ + summary: Type.String(), + }), +}; + +export interface SummaryInput { + model: Model; + apiKey: string; + commitType: string; + scope: string | null; + details: string[]; + stat: string; + maxChars: number; + userContext?: string; +} + +/** + * Generate a commit summary line for the conventional commit header. + */ +export async function generateSummary({ + model, + apiKey, + commitType, + scope, + details, + stat, + maxChars, + userContext, +}: SummaryInput): Promise { + const systemPrompt = renderSummaryPrompt({ commitType, scope, maxChars }); + const userPrompt = renderPromptTemplate(summaryUserPrompt, { + user_context: userContext, + details: details.join("\n"), + stat, + }); + + const response = await completeSimple( + model, + { + systemPrompt, + messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }], + tools: [SummaryTool], + }, + { apiKey, maxTokens: 200 }, + ); + + return parseSummaryFromResponse(response, commitType, scope); +} + +function renderSummaryPrompt({ + commitType, + scope, + maxChars, +}: { + commitType: string; + scope: string | null; + maxChars: number; +}): string { + const scopePrefix = scope ? `(${scope})` : ""; + return renderPromptTemplate(summarySystemPrompt, { + commit_type: commitType, + scope_prefix: scopePrefix, + chars: String(maxChars), + }); +} + +function parseSummaryFromResponse(message: AssistantMessage, commitType: string, scope: string | null): CommitSummary { + const toolCall = extractToolCall(message, "create_commit_summary"); + if (toolCall) { + const parsed = validateToolCall([SummaryTool], toolCall) as { summary: string }; + return { summary: stripTypePrefix(parsed.summary, commitType, scope) }; + } + const text = extractTextContent(message); + return { summary: stripTypePrefix(text, commitType, scope) }; +} + +function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined { + return message.content.find((content) => content.type === "toolCall" && content.name === name) as + | ToolCall + | undefined; +} + +function extractTextContent(message: AssistantMessage): string { + return message.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("") + .trim(); +} + +export function stripTypePrefix(summary: string, commitType: string, scope: string | null): string { + const trimmed = summary.trim(); + const scopePart = scope ? `(${scope})` : ""; + const withScope = `${commitType}${scopePart}: `; + if (trimmed.startsWith(withScope)) { + return trimmed.slice(withScope.length).trim(); + } + const withoutScope = `${commitType}: `; + if (trimmed.startsWith(withoutScope)) { + return trimmed.slice(withoutScope.length).trim(); + } + return trimmed; +} diff --git a/packages/coding-agent/src/commit/analysis/validation.ts b/packages/coding-agent/src/commit/analysis/validation.ts new file mode 100644 index 000000000..0071f710a --- /dev/null +++ b/packages/coding-agent/src/commit/analysis/validation.ts @@ -0,0 +1,66 @@ +import type { ConventionalAnalysis } from "$c/commit/types"; + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +export function validateSummary(summary: string, maxChars: number): ValidationResult { + const errors: string[] = []; + if (!summary.trim()) { + errors.push("Summary is empty"); + } + if (summary.length > maxChars) { + errors.push(`Summary exceeds ${maxChars} characters`); + } + if (summary.trimEnd().endsWith(".")) { + errors.push("Summary must not end with a period"); + } + if (summary.includes("\n")) { + errors.push("Summary must be a single line"); + } + return { valid: errors.length === 0, errors }; +} + +export function validateScope(scope: string | null): ValidationResult { + if (!scope) return { valid: true, errors: [] }; + const errors: string[] = []; + const segments = scope.split("/"); + if (segments.length > 2) { + errors.push("Scope may contain at most two segments"); + } + for (const segment of segments) { + if (!segment) { + errors.push("Scope segments cannot be empty"); + continue; + } + if (segment !== segment.toLowerCase()) { + errors.push("Scope must be lowercase"); + } + if (!/^[a-z0-9][a-z0-9-_]*$/.test(segment)) { + errors.push(`Scope segment has invalid characters: ${segment}`); + } + } + return { valid: errors.length === 0, errors }; +} + +export function validateAnalysis(analysis: ConventionalAnalysis): ValidationResult { + const errors: string[] = []; + const scopeResult = validateScope(analysis.scope); + if (!scopeResult.valid) { + errors.push(...scopeResult.errors); + } + for (const detail of analysis.details) { + if (!detail.text.trim()) { + errors.push("Detail text is empty"); + continue; + } + if (!detail.text.trim().endsWith(".")) { + errors.push(`Detail must end with a period: ${detail.text}`); + } + if (detail.text.length > 120) { + errors.push(`Detail exceeds 120 characters: ${detail.text}`); + } + } + return { valid: errors.length === 0, errors }; +} diff --git a/packages/coding-agent/src/commit/changelog/detect.ts b/packages/coding-agent/src/commit/changelog/detect.ts new file mode 100644 index 000000000..6345b36bd --- /dev/null +++ b/packages/coding-agent/src/commit/changelog/detect.ts @@ -0,0 +1,36 @@ +import { dirname, resolve } from "node:path"; +import type { ChangelogBoundary } from "$c/commit/types"; + +const CHANGELOG_NAME = "CHANGELOG.md"; + +export async function detectChangelogBoundaries(cwd: string, stagedFiles: string[]): Promise { + const boundaries = new Map(); + for (const file of stagedFiles) { + if (file.toLowerCase().endsWith("changelog.md")) continue; + const changelogPath = await findNearestChangelog(cwd, file); + if (!changelogPath) continue; + const list = boundaries.get(changelogPath) ?? []; + list.push(file); + boundaries.set(changelogPath, list); + } + + return Array.from(boundaries.entries()).map(([changelogPath, files]) => ({ + changelogPath, + files, + })); +} + +async function findNearestChangelog(cwd: string, filePath: string): Promise { + let current = resolve(cwd, dirname(filePath)); + const root = resolve(cwd); + while (true) { + const candidate = resolve(current, CHANGELOG_NAME); + if (await Bun.file(candidate).exists()) { + return candidate; + } + if (current === root) return null; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} diff --git a/packages/coding-agent/src/commit/changelog/generate.ts b/packages/coding-agent/src/commit/changelog/generate.ts new file mode 100644 index 000000000..95258db35 --- /dev/null +++ b/packages/coding-agent/src/commit/changelog/generate.ts @@ -0,0 +1,112 @@ +import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; +import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { Type } from "@sinclair/typebox"; +import changelogSystemPrompt from "$c/commit/prompts/changelog-system.md" with { type: "text" }; +import changelogUserPrompt from "$c/commit/prompts/changelog-user.md" with { type: "text" }; +import type { ChangelogGenerationResult } from "$c/commit/types"; +import { renderPromptTemplate } from "$c/config/prompt-templates"; + +const ChangelogTool = { + name: "create_changelog_entries", + description: "Generate changelog entries grouped by Keep a Changelog categories.", + parameters: Type.Object({ + entries: Type.Record(Type.String(), Type.Array(Type.String())), + }), +}; + +export interface ChangelogPromptInput { + model: Model; + apiKey: string; + changelogPath: string; + isPackageChangelog: boolean; + existingEntries?: string; + stat: string; + diff: string; +} + +export async function generateChangelogEntries({ + model, + apiKey, + changelogPath, + isPackageChangelog, + existingEntries, + stat, + diff, +}: ChangelogPromptInput): Promise { + const prompt = renderPromptTemplate(changelogUserPrompt, { + changelog_path: changelogPath, + is_package_changelog: isPackageChangelog, + existing_entries: existingEntries, + stat, + diff, + }); + const response = await completeSimple( + model, + { + systemPrompt: renderPromptTemplate(changelogSystemPrompt), + messages: [{ role: "user", content: prompt, timestamp: Date.now() }], + tools: [ChangelogTool], + }, + { apiKey, maxTokens: 1200 }, + ); + + const parsed = parseChangelogResponse(response); + return { entries: dedupeEntries(parsed.entries) }; +} + +function parseChangelogResponse(message: AssistantMessage): ChangelogGenerationResult { + const toolCall = extractToolCall(message, "create_changelog_entries"); + if (toolCall) { + const parsed = validateToolCall([ChangelogTool], toolCall) as ChangelogGenerationResult; + return { entries: parsed.entries ?? {} }; + } + + const text = extractTextContent(message); + const parsed = parseJsonPayload(text) as ChangelogGenerationResult; + return { entries: parsed.entries ?? {} }; +} + +function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined { + return message.content.find((content) => content.type === "toolCall" && content.name === name) as + | ToolCall + | undefined; +} + +function extractTextContent(message: AssistantMessage): string { + return message.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("") + .trim(); +} + +function parseJsonPayload(text: string): unknown { + const trimmed = text.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return JSON.parse(trimmed) as unknown; + } + const match = trimmed.match(/\{[\s\S]*\}/); + if (!match) { + throw new Error("No JSON payload found in changelog response"); + } + return JSON.parse(match[0]) as unknown; +} + +function dedupeEntries(entries: Record): Record { + const result: Record = {}; + for (const [category, values] of Object.entries(entries)) { + const seen = new Set(); + const cleaned: string[] = []; + for (const value of values) { + const trimmed = value.trim().replace(/\.$/, ""); + const key = trimmed.toLowerCase(); + if (!trimmed || seen.has(key)) continue; + seen.add(key); + cleaned.push(trimmed); + } + if (cleaned.length > 0) { + result[category] = cleaned; + } + } + return result; +} diff --git a/packages/coding-agent/src/commit/changelog/index.ts b/packages/coding-agent/src/commit/changelog/index.ts new file mode 100644 index 000000000..e49ba07b5 --- /dev/null +++ b/packages/coding-agent/src/commit/changelog/index.ts @@ -0,0 +1,140 @@ +import { relative, resolve } from "node:path"; +import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; +import { detectChangelogBoundaries } from "$c/commit/changelog/detect"; +import { generateChangelogEntries } from "$c/commit/changelog/generate"; +import { parseUnreleasedSection } from "$c/commit/changelog/parse"; +import type { ControlledGit } from "$c/commit/git"; + +const CHANGELOG_SECTIONS = ["Breaking Changes", "Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]; + +const MAX_DIFF_CHARS = 120_000; + +export interface ChangelogFlowInput { + git: ControlledGit; + cwd: string; + model: Model; + apiKey: string; + stagedFiles: string[]; + dryRun: boolean; +} + +/** + * Update CHANGELOG.md entries for staged changes. + */ +export async function runChangelogFlow({ + git, + cwd, + model, + apiKey, + stagedFiles, + dryRun, +}: ChangelogFlowInput): Promise { + if (stagedFiles.length === 0) return []; + const boundaries = await detectChangelogBoundaries(cwd, stagedFiles); + if (boundaries.length === 0) return []; + + const updated: string[] = []; + for (const boundary of boundaries) { + const diff = await git.getDiffForFiles(boundary.files, true); + if (!diff.trim()) continue; + const stat = await git.getStatForFiles(boundary.files, true); + const diffForPrompt = truncateDiff(diff, MAX_DIFF_CHARS); + const changelogContent = await Bun.file(boundary.changelogPath).text(); + let unreleased: { startLine: number; endLine: number; entries: Record }; + try { + unreleased = parseUnreleasedSection(changelogContent); + } catch (error) { + logger.warn("commit changelog parse skipped", { path: boundary.changelogPath, error: String(error) }); + continue; + } + const existingEntries = formatExistingEntries(unreleased.entries); + const isPackageChangelog = resolve(boundary.changelogPath) !== resolve(cwd, "CHANGELOG.md"); + const generated = await generateChangelogEntries({ + model, + apiKey, + changelogPath: boundary.changelogPath, + isPackageChangelog, + existingEntries: existingEntries || undefined, + stat, + diff: diffForPrompt, + }); + if (Object.keys(generated.entries).length === 0) continue; + + const updatedContent = applyChangelogEntries(changelogContent, unreleased, generated.entries); + if (!dryRun) { + await Bun.write(boundary.changelogPath, updatedContent); + await git.stageFiles([relative(cwd, boundary.changelogPath)]); + } + updated.push(boundary.changelogPath); + } + + return updated; +} + +function truncateDiff(diff: string, maxChars: number): string { + if (diff.length <= maxChars) return diff; + return `${diff.slice(0, maxChars)}\n... (truncated)`; +} + +function formatExistingEntries(entries: Record): string { + const lines: string[] = []; + for (const section of CHANGELOG_SECTIONS) { + const values = entries[section] ?? []; + if (values.length === 0) continue; + lines.push(`${section}:`); + for (const value of values) { + lines.push(`- ${value}`); + } + } + return lines.join("\n"); +} + +function applyChangelogEntries( + content: string, + unreleased: { startLine: number; endLine: number; entries: Record }, + entries: Record, +): string { + const lines = content.split("\n"); + const before = lines.slice(0, unreleased.startLine + 1); + const after = lines.slice(unreleased.endLine); + + const merged = mergeEntries(unreleased.entries, entries); + const sectionLines = renderUnreleasedSections(merged); + return [...before, ...sectionLines, ...after].join("\n"); +} + +function mergeEntries( + existing: Record, + incoming: Record, +): Record { + const merged: Record = { ...existing }; + for (const [section, items] of Object.entries(incoming)) { + const current = merged[section] ?? []; + const lower = new Set(current.map((item) => item.toLowerCase())); + for (const item of items) { + if (!lower.has(item.toLowerCase())) { + current.push(item); + } + } + merged[section] = current; + } + return merged; +} + +function renderUnreleasedSections(entries: Record): string[] { + const lines: string[] = [""]; + for (const section of CHANGELOG_SECTIONS) { + const items = entries[section] ?? []; + if (items.length === 0) continue; + lines.push(`### ${section}`); + for (const item of items) { + lines.push(`- ${item}`); + } + lines.push(""); + } + if (lines[lines.length - 1] === "") { + lines.pop(); + } + return lines; +} diff --git a/packages/coding-agent/src/commit/changelog/parse.ts b/packages/coding-agent/src/commit/changelog/parse.ts new file mode 100644 index 000000000..55da2126c --- /dev/null +++ b/packages/coding-agent/src/commit/changelog/parse.ts @@ -0,0 +1,44 @@ +import type { UnreleasedSection } from "$c/commit/types"; + +const UNRELEASED_PATTERN = /^##\s+\[?Unreleased\]?/i; +const SECTION_PATTERN = /^###\s+(.*)$/; + +export function parseUnreleasedSection(content: string): UnreleasedSection { + const lines = content.split("\n"); + const startIndex = lines.findIndex((line) => UNRELEASED_PATTERN.test(line.trim())); + if (startIndex === -1) { + throw new Error("No [Unreleased] section found in changelog"); + } + + let endIndex = lines.length; + for (let i = startIndex + 1; i < lines.length; i += 1) { + if (lines[i]?.startsWith("## ")) { + endIndex = i; + break; + } + } + + const sectionLines = lines.slice(startIndex + 1, endIndex); + const entries: Record = {}; + let currentSection: string | null = null; + for (const line of sectionLines) { + const sectionMatch = line.match(SECTION_PATTERN); + if (sectionMatch) { + currentSection = sectionMatch[1]?.trim() || null; + if (currentSection) { + entries[currentSection] = entries[currentSection] ?? []; + } + continue; + } + + if (!currentSection) continue; + const trimmed = line.trim(); + if (!trimmed.startsWith("-")) continue; + const entry = trimmed.replace(/^[-*]\s*/, ""); + if (entry) { + entries[currentSection]?.push(entry); + } + } + + return { startLine: startIndex, endLine: endIndex, entries }; +} diff --git a/packages/coding-agent/src/commit/cli.ts b/packages/coding-agent/src/commit/cli.ts new file mode 100644 index 000000000..04b679df8 --- /dev/null +++ b/packages/coding-agent/src/commit/cli.ts @@ -0,0 +1,89 @@ +import chalk from "chalk"; +import type { CommitCommandArgs } from "./types"; + +const FLAG_ALIASES = new Map([ + ["-c", "--context"], + ["-m", "--model"], +]); + +export function parseCommitArgs(args: string[]): CommitCommandArgs | undefined { + if (args.length === 0 || args[0] !== "commit") { + return undefined; + } + + const result: CommitCommandArgs = { + push: false, + dryRun: false, + noChangelog: false, + }; + + for (let i = 1; i < args.length; i += 1) { + const raw = args[i] ?? ""; + const flag = FLAG_ALIASES.get(raw) ?? raw; + switch (flag) { + case "--push": + result.push = true; + break; + case "--dry-run": + result.dryRun = true; + break; + case "--no-changelog": + result.noChangelog = true; + break; + case "--context": { + const value = args[i + 1]; + if (!value || value.startsWith("-")) { + writeStderr(chalk.red("Error: --context requires a value")); + process.exit(1); + } + result.context = value; + i += 1; + break; + } + case "--model": { + const value = args[i + 1]; + if (!value || value.startsWith("-")) { + writeStderr(chalk.red("Error: --model requires a value")); + process.exit(1); + } + result.model = value; + i += 1; + break; + } + case "--help": + case "-h": + break; + default: + if (flag.startsWith("-")) { + writeStderr(chalk.red(`Error: Unknown flag ${flag}`)); + process.exit(1); + } + } + } + + return result; +} + +export function printCommitHelp(): void { + const lines = [ + "Usage:", + " omp commit [options]", + "", + "Options:", + " --push Push after committing", + " --dry-run Preview without committing", + " --no-changelog Skip changelog updates", + " --context, -c Additional context for the model", + " --model, -m Override model selection", + " --help, -h Show this help message", + ]; + writeStdout(lines.join("\n")); +} + +function writeStdout(message: string): void { + process.stdout.write(`${message}\n`); +} + +function writeStderr(message: string): void { + process.stderr.write(`${message}\n`); +} diff --git a/packages/coding-agent/src/commit/git/diff.ts b/packages/coding-agent/src/commit/git/diff.ts new file mode 100644 index 000000000..6ee53327b --- /dev/null +++ b/packages/coding-agent/src/commit/git/diff.ts @@ -0,0 +1,73 @@ +import type { FileDiff, NumstatEntry } from "$c/commit/types"; + +export function parseNumstat(output: string): NumstatEntry[] { + const entries: NumstatEntry[] = []; + for (const line of output.split("\n")) { + if (!line.trim()) continue; + const parts = line.split("\t"); + if (parts.length < 3) continue; + const [addedRaw, deletedRaw, pathRaw] = parts; + const additions = Number.parseInt(addedRaw, 10); + const deletions = Number.parseInt(deletedRaw, 10); + const path = extractPathFromRename(pathRaw); + entries.push({ + path, + additions: Number.isNaN(additions) ? 0 : additions, + deletions: Number.isNaN(deletions) ? 0 : deletions, + }); + } + return entries; +} + +export function parseFileDiffs(diff: string): FileDiff[] { + const sections: FileDiff[] = []; + const parts = diff.split("\ndiff --git "); + for (let index = 0; index < parts.length; index += 1) { + const part = index === 0 ? parts[index] : `diff --git ${parts[index]}`; + if (!part.trim()) continue; + const lines = part.split("\n"); + const header = lines[0] ?? ""; + const match = header.match(/diff --git a\/(.+?) b\/(.+)$/); + if (!match) continue; + const filename = match[2]; + const content = part; + const isBinary = lines.some((line) => line.startsWith("Binary files ")); + let additions = 0; + let deletions = 0; + for (const line of lines) { + if (line.startsWith("+++") || line.startsWith("---")) continue; + if (line.startsWith("+")) additions += 1; + else if (line.startsWith("-")) deletions += 1; + } + sections.push({ + filename, + content, + additions, + deletions, + isBinary, + }); + } + return sections; +} + +function extractPathFromRename(pathPart: string): string { + const braceStart = pathPart.indexOf("{"); + if (braceStart !== -1) { + const arrowPos = pathPart.indexOf(" => ", braceStart); + if (arrowPos !== -1) { + const braceEnd = pathPart.indexOf("}", arrowPos); + if (braceEnd !== -1) { + const prefix = pathPart.slice(0, braceStart); + const newName = pathPart.slice(arrowPos + 4, braceEnd).trim(); + return `${prefix}${newName}`; + } + } + } + + if (pathPart.includes(" => ")) { + const parts = pathPart.split(" => "); + return parts[1]?.trim() ?? pathPart.trim(); + } + + return pathPart.trim(); +} diff --git a/packages/coding-agent/src/commit/git/index.ts b/packages/coding-agent/src/commit/git/index.ts new file mode 100644 index 000000000..2fcfc981f --- /dev/null +++ b/packages/coding-agent/src/commit/git/index.ts @@ -0,0 +1,92 @@ +import { logger } from "@oh-my-pi/pi-utils"; +import { parseFileDiffs, parseNumstat } from "$c/commit/git/diff"; +import { commit, push, runGitCommand, stageFiles } from "$c/commit/git/operations"; +import type { FileDiff, NumstatEntry } from "$c/commit/types"; + +export class ControlledGit { + constructor(private readonly cwd: string) {} + + async getDiff(staged: boolean): Promise { + const args = staged ? ["diff", "--cached"] : ["diff"]; + const result = await runGitCommand(this.cwd, args); + this.ensureSuccess(result, "git diff"); + return result.stdout; + } + + async getDiffForFiles(files: string[], staged = true): Promise { + const args = staged ? ["diff", "--cached", "--", ...files] : ["diff", "--", ...files]; + const result = await runGitCommand(this.cwd, args); + this.ensureSuccess(result, "git diff (files)"); + return result.stdout; + } + + async getStat(staged: boolean): Promise { + const args = staged ? ["diff", "--cached", "--stat"] : ["diff", "--stat"]; + const result = await runGitCommand(this.cwd, args); + this.ensureSuccess(result, "git diff --stat"); + return result.stdout; + } + + async getStatForFiles(files: string[], staged = true): Promise { + const args = staged ? ["diff", "--cached", "--stat", "--", ...files] : ["diff", "--stat", "--", ...files]; + const result = await runGitCommand(this.cwd, args); + this.ensureSuccess(result, "git diff --stat (files)"); + return result.stdout; + } + + async getNumstat(staged: boolean): Promise { + const args = staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat"]; + const result = await runGitCommand(this.cwd, args); + this.ensureSuccess(result, "git diff --numstat"); + return parseNumstat(result.stdout); + } + + async getRecentCommits(count: number): Promise { + const result = await runGitCommand(this.cwd, ["log", `-n${count}`, "--pretty=format:%s"]); + this.ensureSuccess(result, "git log"); + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + } + + async getStagedFiles(): Promise { + const result = await runGitCommand(this.cwd, ["diff", "--cached", "--name-only"]); + this.ensureSuccess(result, "git diff --cached --name-only"); + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + } + + async stageAll(): Promise { + const result = await stageFiles(this.cwd, []); + this.ensureSuccess(result, "git add -A"); + } + + async stageFiles(files: string[]): Promise { + const result = await stageFiles(this.cwd, files); + this.ensureSuccess(result, "git add"); + } + + async commit(message: string): Promise { + const result = await commit(this.cwd, message); + this.ensureSuccess(result, "git commit"); + } + + async push(): Promise { + const result = await push(this.cwd); + this.ensureSuccess(result, "git push"); + } + + parseDiffFiles(diff: string): FileDiff[] { + return parseFileDiffs(diff); + } + + private ensureSuccess(result: { exitCode: number; stderr: string }, label: string): void { + if (result.exitCode !== 0) { + logger.error("commit git command failed", { label, stderr: result.stderr }); + throw new Error(`${label} failed: ${result.stderr || "unknown error"}`); + } + } +} diff --git a/packages/coding-agent/src/commit/git/operations.ts b/packages/coding-agent/src/commit/git/operations.ts new file mode 100644 index 000000000..2b8f542b7 --- /dev/null +++ b/packages/coding-agent/src/commit/git/operations.ts @@ -0,0 +1,52 @@ +import { $ } from "bun"; + +interface GitResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export async function runGitCommand(cwd: string, args: string[]): Promise { + const result = await $`git ${args}`.cwd(cwd).quiet().nothrow(); + const stdout = result.text(); + const stderr = result.stderr?.toString() ?? ""; + return { + exitCode: result.exitCode ?? 0, + stdout, + stderr, + }; +} + +export async function stageFiles(cwd: string, files: string[]): Promise { + const args = files.length === 0 ? ["add", "-A"] : ["add", "--", ...files]; + return runGitCommand(cwd, args); +} + +export async function push(cwd: string): Promise { + return runGitCommand(cwd, ["push"]); +} + +export async function commit(cwd: string, message: string): Promise { + const child = Bun.spawn(["git", "commit", "-F", "-"], { + cwd, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode(message)); + await writer.close(); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + + return { + exitCode: exitCode ?? 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + }; +} diff --git a/packages/coding-agent/src/commit/index.ts b/packages/coding-agent/src/commit/index.ts new file mode 100644 index 000000000..1bdf6a5b8 --- /dev/null +++ b/packages/coding-agent/src/commit/index.ts @@ -0,0 +1,5 @@ +/** + * Entry points for the omp commit command. + */ + +export { runCommitCommand } from "./pipeline"; diff --git a/packages/coding-agent/src/commit/map-reduce/index.ts b/packages/coding-agent/src/commit/map-reduce/index.ts new file mode 100644 index 000000000..7ad66c7c6 --- /dev/null +++ b/packages/coding-agent/src/commit/map-reduce/index.ts @@ -0,0 +1,49 @@ +import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { parseFileDiffs } from "$c/commit/git/diff"; +import { runMapPhase } from "$c/commit/map-reduce/map-phase"; +import { runReducePhase } from "$c/commit/map-reduce/reduce-phase"; +import { estimateTokens } from "$c/commit/map-reduce/utils"; +import type { ConventionalAnalysis } from "$c/commit/types"; +import { isExcludedFile } from "$c/commit/utils/exclusions"; + +const MIN_FILES_FOR_MAP_REDUCE = 4; +const MAX_FILE_TOKENS = 50_000; + +export interface MapReduceInput { + model: Model; + apiKey: string; + smolModel: Model; + smolApiKey: string; + diff: string; + stat: string; + scopeCandidates: string; + typesDescription?: string; +} + +export function shouldUseMapReduce(diff: string): boolean { + if (process.env.OMP_COMMIT_MAP_REDUCE?.toLowerCase() === "false") return false; + const files = parseFileDiffs(diff).filter((file) => !isExcludedFile(file.filename)); + const fileCount = files.length; + if (fileCount >= MIN_FILES_FOR_MAP_REDUCE) return true; + return files.some((file) => estimateTokens(file.content) > MAX_FILE_TOKENS); +} + +/** + * Run map-reduce analysis for large diffs using smol + primary models. + */ +export async function runMapReduceAnalysis(input: MapReduceInput): Promise { + const fileDiffs = parseFileDiffs(input.diff).filter((file) => !isExcludedFile(file.filename)); + const observations = await runMapPhase({ + model: input.smolModel, + apiKey: input.smolApiKey, + files: fileDiffs, + }); + return runReducePhase({ + model: input.model, + apiKey: input.apiKey, + observations, + stat: input.stat, + scopeCandidates: input.scopeCandidates, + typesDescription: input.typesDescription, + }); +} diff --git a/packages/coding-agent/src/commit/map-reduce/map-phase.ts b/packages/coding-agent/src/commit/map-reduce/map-phase.ts new file mode 100644 index 000000000..577e0b365 --- /dev/null +++ b/packages/coding-agent/src/commit/map-reduce/map-phase.ts @@ -0,0 +1,173 @@ +import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; +import { completeSimple } from "@oh-my-pi/pi-ai"; +import { truncateToTokenLimit } from "$c/commit/map-reduce/utils"; +import fileObserverSystemPrompt from "$c/commit/prompts/file-observer-system.md" with { type: "text" }; +import fileObserverUserPrompt from "$c/commit/prompts/file-observer-user.md" with { type: "text" }; +import type { FileDiff, FileObservation } from "$c/commit/types"; +import { isExcludedFile } from "$c/commit/utils/exclusions"; +import { renderPromptTemplate } from "$c/config/prompt-templates"; + +const MAX_FILE_TOKENS = 50_000; +const MAX_CONTEXT_FILES = 20; +const MAX_CONCURRENCY = 5; +const MAP_PHASE_TIMEOUT_MS = 120_000; +const MAX_RETRIES = 3; +const RETRY_BACKOFF_MS = 1000; + +export interface MapPhaseInput { + model: Model; + apiKey: string; + files: FileDiff[]; +} + +export async function runMapPhase({ model, apiKey, files }: MapPhaseInput): Promise { + const filtered = files.filter((file) => !isExcludedFile(file.filename)); + const systemPrompt = renderPromptTemplate(fileObserverSystemPrompt); + return runWithConcurrency(filtered, MAX_CONCURRENCY, async (file) => { + if (file.isBinary) { + return { + file: file.filename, + observations: ["Binary file changed."], + additions: file.additions, + deletions: file.deletions, + }; + } + + const contextHeader = generateContextHeader(filtered, file.filename); + const truncated = truncateToTokenLimit(file.content, MAX_FILE_TOKENS); + const prompt = renderPromptTemplate(fileObserverUserPrompt, { + filename: file.filename, + diff: truncated, + context_header: contextHeader, + }); + + const response = await withRetry(async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), MAP_PHASE_TIMEOUT_MS); + try { + return await completeSimple( + model, + { + systemPrompt: systemPrompt, + messages: [{ role: "user", content: prompt, timestamp: Date.now() }], + }, + { apiKey, maxTokens: 400, signal: controller.signal }, + ); + } finally { + clearTimeout(timeout); + } + }, MAX_RETRIES); + + const observations = parseObservations(response); + return { + file: file.filename, + observations, + additions: file.additions, + deletions: file.deletions, + }; + }); +} + +function parseObservations(message: AssistantMessage): string[] { + const text = message.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("") + .trim(); + + if (!text) return []; + + const lines = text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/^[-*]\s+/, "")) + .filter(Boolean); + + return lines.slice(0, 5); +} + +function generateContextHeader(files: FileDiff[], currentFile: string): string { + if (files.length > 100) { + return `(Large commit with ${files.length} total files)`; + } + + const otherFiles = files.filter((file) => file.filename !== currentFile); + if (otherFiles.length === 0) return ""; + + const sorted = [...otherFiles].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions)); + const toShow = sorted.length > MAX_CONTEXT_FILES ? sorted.slice(0, MAX_CONTEXT_FILES) : sorted; + + const lines = ["OTHER FILES IN THIS CHANGE:"]; + for (const file of toShow) { + const lineCount = file.additions + file.deletions; + const description = inferFileDescription(file); + lines.push(`- ${file.filename} (${lineCount} lines): ${description}`); + } + + if (toShow.length < sorted.length) { + lines.push(`... and ${sorted.length - toShow.length} more files`); + } + + return lines.join("\n"); +} + +function inferFileDescription(file: FileDiff): string { + const filenameLower = file.filename.toLowerCase(); + if (filenameLower.includes("test")) return "test file"; + if (filenameLower.endsWith(".md")) return "documentation"; + if ( + filenameLower.includes("config") || + filenameLower.endsWith(".toml") || + filenameLower.endsWith(".yaml") || + filenameLower.endsWith(".yml") + ) { + return "configuration"; + } + if (filenameLower.includes("error")) return "error definitions"; + if (filenameLower.includes("type")) return "type definitions"; + if (filenameLower.endsWith("mod.rs") || filenameLower.endsWith("lib.rs")) return "module exports"; + if (filenameLower.endsWith("main.rs") || filenameLower.endsWith("main.go") || filenameLower.endsWith("main.py")) { + return "entry point"; + } + + const content = file.content; + if (content.includes("interface ") || content.includes("type ")) return "type definitions"; + if (content.includes("class ") || content.includes("function ") || content.includes("fn ")) return "implementation"; + if (content.includes("async ") || content.includes("await")) return "async code"; + return "source code"; +} + +async function runWithConcurrency( + items: T[], + limit: number, + worker: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (true) { + const current = nextIndex; + nextIndex += 1; + if (current >= items.length) return; + results[current] = await worker(items[current] as T, current); + } + }); + await Promise.all(runners); + return results; +} + +async function withRetry(fn: () => Promise, attempts: number): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (attempt < attempts - 1) { + await Bun.sleep(RETRY_BACKOFF_MS * (attempt + 1)); + } + } + } + throw lastError; +} diff --git a/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts new file mode 100644 index 000000000..801774075 --- /dev/null +++ b/packages/coding-agent/src/commit/map-reduce/reduce-phase.ts @@ -0,0 +1,147 @@ +import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; +import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; +import { Type } from "@sinclair/typebox"; +import reduceSystemPrompt from "$c/commit/prompts/reduce-system.md" with { type: "text" }; +import reduceUserPrompt from "$c/commit/prompts/reduce-user.md" with { type: "text" }; +import type { ChangelogCategory, ConventionalAnalysis, FileObservation } from "$c/commit/types"; +import { renderPromptTemplate } from "$c/config/prompt-templates"; + +const ReduceTool = { + name: "create_conventional_analysis", + description: "Synthesize file observations into a conventional commit analysis.", + parameters: Type.Object({ + type: Type.Union([ + Type.Literal("feat"), + Type.Literal("fix"), + Type.Literal("refactor"), + Type.Literal("docs"), + Type.Literal("test"), + Type.Literal("chore"), + Type.Literal("style"), + Type.Literal("perf"), + Type.Literal("build"), + Type.Literal("ci"), + Type.Literal("revert"), + ]), + scope: Type.Union([Type.String(), Type.Null()]), + details: Type.Array( + Type.Object({ + text: Type.String(), + changelog_category: Type.Optional( + Type.Union([ + Type.Literal("Added"), + Type.Literal("Changed"), + Type.Literal("Fixed"), + Type.Literal("Deprecated"), + Type.Literal("Removed"), + Type.Literal("Security"), + Type.Literal("Breaking Changes"), + ]), + ), + user_visible: Type.Optional(Type.Boolean()), + }), + ), + issue_refs: Type.Array(Type.String()), + }), +}; + +export interface ReducePhaseInput { + model: Model; + apiKey: string; + observations: FileObservation[]; + stat: string; + scopeCandidates: string; + typesDescription?: string; +} + +export async function runReducePhase({ + model, + apiKey, + observations, + stat, + scopeCandidates, + typesDescription, +}: ReducePhaseInput): Promise { + const prompt = renderPromptTemplate(reduceUserPrompt, { + types_description: typesDescription, + observations: observations.flatMap((obs) => obs.observations.map((line) => `- ${obs.file}: ${line}`)).join("\n"), + stat, + scope_candidates: scopeCandidates, + }); + const response = await completeSimple( + model, + { + systemPrompt: renderPromptTemplate(reduceSystemPrompt), + messages: [{ role: "user", content: prompt, timestamp: Date.now() }], + tools: [ReduceTool], + }, + { apiKey, maxTokens: 2400 }, + ); + + return parseAnalysisResponse(response); +} + +function parseAnalysisResponse(message: AssistantMessage): ConventionalAnalysis { + const toolCall = extractToolCall(message, "create_conventional_analysis"); + if (toolCall) { + const parsed = validateToolCall([ReduceTool], toolCall) as { + type: ConventionalAnalysis["type"]; + scope: string | null; + details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; + issue_refs: string[]; + }; + return normalizeAnalysis(parsed); + } + const text = extractTextContent(message); + const parsed = parseJsonPayload(text) as { + type: ConventionalAnalysis["type"]; + scope: string | null; + details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; + issue_refs: string[]; + }; + return normalizeAnalysis(parsed); +} + +function parseJsonPayload(text: string): unknown { + const trimmed = text.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return JSON.parse(trimmed) as unknown; + } + const match = trimmed.match(/\{[\s\S]*\}/); + if (!match) { + throw new Error("No JSON payload found in reduce response"); + } + return JSON.parse(match[0]) as unknown; +} + +function normalizeAnalysis(parsed: { + type: ConventionalAnalysis["type"]; + scope: string | null; + details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>; + issue_refs: string[]; +}): ConventionalAnalysis { + return { + type: parsed.type, + scope: parsed.scope?.trim() || null, + details: parsed.details.map((detail) => ({ + text: detail.text.trim(), + changelogCategory: detail.user_visible ? detail.changelog_category : undefined, + userVisible: detail.user_visible ?? false, + })), + issueRefs: parsed.issue_refs ?? [], + }; +} + +function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined { + return message.content.find((content) => content.type === "toolCall" && content.name === name) as + | ToolCall + | undefined; +} + +function extractTextContent(message: AssistantMessage): string { + return message.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("") + .trim(); +} diff --git a/packages/coding-agent/src/commit/map-reduce/utils.ts b/packages/coding-agent/src/commit/map-reduce/utils.ts new file mode 100644 index 000000000..c12860323 --- /dev/null +++ b/packages/coding-agent/src/commit/map-reduce/utils.ts @@ -0,0 +1,9 @@ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +export function truncateToTokenLimit(text: string, maxTokens: number): string { + const maxChars = maxTokens * 4; + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n... (truncated)`; +} diff --git a/packages/coding-agent/src/commit/pipeline.ts b/packages/coding-agent/src/commit/pipeline.ts new file mode 100644 index 000000000..706430a7b --- /dev/null +++ b/packages/coding-agent/src/commit/pipeline.ts @@ -0,0 +1,277 @@ +import { relative } from "node:path"; +import type { Api, Model } from "@oh-my-pi/pi-ai"; +import { logger } from "@oh-my-pi/pi-utils"; +import { renderPromptTemplate } from "$c/config/prompt-templates"; +import { parseModelPattern, parseModelString, SMOL_MODEL_PRIORITY } from "$c/config/model-resolver"; +import { SettingsManager } from "$c/config/settings-manager"; +import { discoverAuthStorage, discoverModels } from "$c/sdk"; +import { loadProjectContextFiles } from "$c/system-prompt"; +import typesDescriptionPrompt from "$c/commit/prompts/types-description.md" with { type: "text" }; +import { + extractScopeCandidates, + generateConventionalAnalysis, + generateSummary, + validateAnalysis, + validateSummary, +} from "./analysis"; +import { runChangelogFlow } from "./changelog"; +import { ControlledGit } from "./git"; +import { runMapReduceAnalysis, shouldUseMapReduce } from "./map-reduce"; +import type { CommitCommandArgs, ConventionalAnalysis } from "./types"; + +const SUMMARY_MAX_CHARS = 72; +const RECENT_COMMITS_COUNT = 8; +const TYPES_DESCRIPTION = renderPromptTemplate(typesDescriptionPrompt); + +/** + * Execute the omp commit pipeline for staged changes. + */ +export async function runCommitCommand(args: CommitCommandArgs): Promise { + const cwd = process.cwd(); + const settingsManager = await SettingsManager.create(cwd); + const authStorage = await discoverAuthStorage(); + const modelRegistry = await discoverModels(authStorage); + + const { model: primaryModel, apiKey: primaryApiKey } = await resolvePrimaryModel( + args.model, + settingsManager, + modelRegistry, + ); + const { model: smolModel, apiKey: smolApiKey } = await resolveSmolModel( + settingsManager, + modelRegistry, + primaryModel, + primaryApiKey, + ); + + const git = new ControlledGit(cwd); + let stagedFiles = await git.getStagedFiles(); + if (stagedFiles.length === 0) { + writeStdout("No staged changes detected, staging all changes..."); + await git.stageAll(); + stagedFiles = await git.getStagedFiles(); + } + if (stagedFiles.length === 0) { + writeStderr("No changes to commit."); + return; + } + + if (!args.noChangelog) { + await runChangelogFlow({ + git, + cwd, + model: primaryModel, + apiKey: primaryApiKey, + stagedFiles, + dryRun: args.dryRun, + }); + } + + const diff = await git.getDiff(true); + const stat = await git.getStat(true); + const numstat = await git.getNumstat(true); + const scopeCandidates = extractScopeCandidates(numstat).scopeCandidates; + const recentCommits = await git.getRecentCommits(RECENT_COMMITS_COUNT); + const contextFiles = await loadProjectContextFiles({ cwd }); + const formattedContextFiles = contextFiles.map((file) => ({ + path: relative(cwd, file.path), + content: file.content, + })); + + const analysis = await generateAnalysis({ + diff, + stat, + scopeCandidates, + recentCommits, + contextFiles: formattedContextFiles, + userContext: args.context, + primaryModel, + primaryApiKey, + smolModel, + smolApiKey, + }); + + const analysisValidation = validateAnalysis(analysis); + if (!analysisValidation.valid) { + logger.warn("commit analysis validation failed", { errors: analysisValidation.errors }); + } + + const summary = await generateSummaryWithRetry({ + analysis, + stat, + model: primaryModel, + apiKey: primaryApiKey, + userContext: args.context, + }); + + const commitMessage = formatCommitMessage(analysis, summary.summary); + + if (args.dryRun) { + writeStdout("\nGenerated commit message:\n"); + writeStdout(commitMessage); + return; + } + + await git.commit(commitMessage); + writeStdout("Commit created."); + if (args.push) { + await git.push(); + writeStdout("Pushed to remote."); + } +} + +async function generateAnalysis(input: { + diff: string; + stat: string; + scopeCandidates: string; + recentCommits: string[]; + contextFiles: Array<{ path: string; content: string }>; + userContext?: string; + primaryModel: Model; + primaryApiKey: string; + smolModel: Model; + smolApiKey: string; +}): Promise { + if (shouldUseMapReduce(input.diff)) { + writeStdout("Large diff detected, using map-reduce analysis..."); + return runMapReduceAnalysis({ + model: input.primaryModel, + apiKey: input.primaryApiKey, + smolModel: input.smolModel, + smolApiKey: input.smolApiKey, + diff: input.diff, + stat: input.stat, + scopeCandidates: input.scopeCandidates, + typesDescription: TYPES_DESCRIPTION, + }); + } + + return generateConventionalAnalysis({ + model: input.primaryModel, + apiKey: input.primaryApiKey, + contextFiles: input.contextFiles, + userContext: input.userContext, + typesDescription: TYPES_DESCRIPTION, + recentCommits: input.recentCommits, + scopeCandidates: input.scopeCandidates, + stat: input.stat, + diff: input.diff, + }); +} + +async function generateSummaryWithRetry(input: { + analysis: ConventionalAnalysis; + stat: string; + model: Model; + apiKey: string; + userContext?: string; +}): Promise<{ summary: string }> { + let context = input.userContext; + for (let attempt = 0; attempt < 3; attempt += 1) { + const result = await generateSummary({ + model: input.model, + apiKey: input.apiKey, + commitType: input.analysis.type, + scope: input.analysis.scope, + details: input.analysis.details.map((detail) => detail.text), + stat: input.stat, + maxChars: SUMMARY_MAX_CHARS, + userContext: context, + }); + const validation = validateSummary(result.summary, SUMMARY_MAX_CHARS); + if (validation.valid) { + return result; + } + if (attempt === 2) { + return result; + } + context = buildRetryContext(input.userContext, validation.errors); + } + throw new Error("Summary generation failed"); +} + +function buildRetryContext(base: string | undefined, errors: string[]): string { + const parts = [base, `Previous summary failed validation: ${errors.join("; ")}`].filter(Boolean); + return parts.join("\n"); +} + +function formatCommitMessage(analysis: ConventionalAnalysis, summary: string): string { + const scopePart = analysis.scope ? `(${analysis.scope})` : ""; + const header = `${analysis.type}${scopePart}: ${summary}`; + const bodyLines = analysis.details.map((detail) => `- ${detail.text.trim()}`); + if (bodyLines.length === 0) { + return header; + } + return `${header}\n\n${bodyLines.join("\n")}`; +} + +async function resolvePrimaryModel( + override: string | undefined, + settingsManager: SettingsManager, + modelRegistry: { + getAvailable: () => Model[]; + getApiKey: (model: Model) => Promise; + }, +): Promise<{ model: Model; apiKey: string }> { + const available = modelRegistry.getAvailable(); + const model = override + ? resolveModelFromString(override, available) + : resolveModelFromSettings(settingsManager, available); + if (!model) { + throw new Error("No model available for commit generation"); + } + const apiKey = await modelRegistry.getApiKey(model); + if (!apiKey) { + throw new Error(`No API key available for model ${model.provider}/${model.id}`); + } + return { model, apiKey }; +} + +async function resolveSmolModel( + settingsManager: SettingsManager, + modelRegistry: { + getAvailable: () => Model[]; + getApiKey: (model: Model) => Promise; + }, + fallbackModel: Model, + fallbackApiKey: string, +): Promise<{ model: Model; apiKey: string }> { + const available = modelRegistry.getAvailable(); + const role = settingsManager.getModelRole("smol"); + const roleModel = role ? resolveModelFromString(role, available) : undefined; + if (roleModel) { + const apiKey = await modelRegistry.getApiKey(roleModel); + if (apiKey) return { model: roleModel, apiKey }; + } + + for (const pattern of SMOL_MODEL_PRIORITY) { + const candidate = parseModelPattern(pattern, available).model; + if (!candidate) continue; + const apiKey = await modelRegistry.getApiKey(candidate); + if (apiKey) return { model: candidate, apiKey }; + } + + return { model: fallbackModel, apiKey: fallbackApiKey }; +} + +function resolveModelFromSettings(settingsManager: SettingsManager, available: Model[]): Model | undefined { + const configured = settingsManager.getModelRole("default"); + if (!configured) return available[0]; + return resolveModelFromString(configured, available) ?? available[0]; +} + +function resolveModelFromString(value: string, available: Model[]): Model | undefined { + const parsed = parseModelString(value); + if (parsed) { + return available.find((model) => model.provider === parsed.provider && model.id === parsed.id); + } + return parseModelPattern(value, available).model; +} + +function writeStdout(message: string): void { + process.stdout.write(`${message}\n`); +} + +function writeStderr(message: string): void { + process.stderr.write(`${message}\n`); +} diff --git a/packages/coding-agent/src/commit/prompts/analysis-system.md b/packages/coding-agent/src/commit/prompts/analysis-system.md new file mode 100644 index 000000000..1753cd052 --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/analysis-system.md @@ -0,0 +1,155 @@ + +You are a senior release engineer who writes precise, changelog-ready commit classifications. Your output feeds directly into automated release tooling. + + + +Classify this git diff into conventional commit format. Get this right — it affects release notes and semantic versioning. + +## 1. Determine Scope + +Apply scope when 60%+ of line changes target a single component: +- 150 lines in src/api/, 30 in src/lib.rs -> "api" +- 50 lines in src/api/, 50 in src/types/ -> null (50/50 split) + +Use null for: cross-cutting changes, no dominant component, project-wide refactoring. + +Forbidden scopes (use null): src, lib, include, tests, benches, examples, docs, project name, app, main, entire, all, misc. + +Prefer scopes from over inventing new ones. + +## 2. Generate Details (0-6 items) + +Each detail: +1. Past-tense verb, ends with period +2. Explains impact/rationale (skip trivial what-changed) +3. Uses precise names (modules, APIs, files) +4. Under 120 characters + +Abstraction preference: +- BEST: "Replaced polling with event-driven model for 10x throughput." +- GOOD: "Consolidated three HTTP builders into unified API." +- SKIP: "Renamed workspacePath to locate." + +Group 3+ similar changes: "Updated 5 test files for new API." (not five bullets). + +Issue references inline: (#123), (#123, #456), (#123-#125). + +Priority: user-visible -> perf/security -> architecture -> internal. + +Exclude: import changes, whitespace, formatting, trivial renames, debug prints, comment-only, file moves without modification. + +State only visible rationale. If unclear, use neutral: "Updated logic for correctness." + +## 3. Assign Changelog Metadata + +| Condition | changelog_category | +|-----------|--------------------| +| New public API, feature, capability | "Added" | +| Modified existing behavior | "Changed" | +| Bug fix, correction | "Fixed" | +| Feature marked for removal | "Deprecated" | +| Feature/API removed | "Removed" | +| Security fix or improvement | "Security" | + +user_visible: true for: new features, APIs, breaking changes, user-affecting bug fixes, user-facing docs, security fixes. + +user_visible: false for: internal refactoring, performance optimizations (unless documented), test/build/CI, code style. + +Omit changelog_category when user_visible is false. + + + +Call create_conventional_analysis with: + +{ + "type": "feat|fix|refactor|docs|test|chore|style|perf|build|ci|revert", + "scope": "component-name" | null, + "details": [ + { + "text": "Past-tense description ending with period.", + "changelog_category": "Added|Changed|Fixed|Deprecated|Removed|Security", + "user_visible": true + }, + { + "text": "Internal change description.", + "user_visible": false + } + ], + "issue_refs": [] +} + + + + +{ + "type": "feat", + "scope": "api", + "details": [ + { + "text": "Added TLS mutual authentication to prevent man-in-the-middle attacks (#100).", + "changelog_category": "Added", + "user_visible": true + }, + { + "text": "Implemented builder pattern to simplify transport configuration (#101).", + "changelog_category": "Added", + "user_visible": true + }, + { + "text": "Migrated 6 integration tests to exercise new security features.", + "user_visible": false + } + ], + "issue_refs": [] +} + + + +{ + "type": "refactor", + "scope": "parser", + "details": [ + { + "text": "Extracted validation logic into separate module for reusability.", + "user_visible": false + }, + { + "text": "Consolidated error handling across 12 functions to reduce duplication.", + "user_visible": false + } + ], + "issue_refs": [] +} + + + +{ + "type": "fix", + "scope": "parser", + "details": [ + { + "text": "Corrected off-by-one error causing buffer overflow on large inputs (#456).", + "changelog_category": "Fixed", + "user_visible": true + }, + { + "text": "Added bounds checking to prevent panic on empty files (#457).", + "changelog_category": "Fixed", + "user_visible": true + } + ], + "issue_refs": [] +} + + + +{ + "type": "chore", + "scope": "deps", + "details": [], + "issue_refs": [] +} + + + +Be thorough. This matters. diff --git a/packages/coding-agent/src/commit/prompts/analysis-user.md b/packages/coding-agent/src/commit/prompts/analysis-user.md new file mode 100644 index 000000000..cf1303b75 --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/analysis-user.md @@ -0,0 +1,41 @@ +{{#if context_files}} + +{{#each context_files}} + +{{ content }} + +{{/each}} + +{{/if}} +{{#if user_context}} + +{{ user_context }} + +{{/if}} +{{#if types_description}} + +{{ types_description }} + +{{/if}} + + +{{ stat }} + + + +{{ scope_candidates }} + +{{#if common_scopes}} + +{{ common_scopes }} + +{{/if}} +{{#if recent_commits}} + +{{ recent_commits }} + +{{/if}} + + +{{ diff }} + diff --git a/packages/coding-agent/src/commit/prompts/changelog-system.md b/packages/coding-agent/src/commit/prompts/changelog-system.md new file mode 100644 index 000000000..38a8fb875 --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/changelog-system.md @@ -0,0 +1,56 @@ +You are an expert changelog writer who analyzes git diffs and produces Keep a Changelog entries. Get this right—changelogs are how users understand what changed. + + +Analyze the diff and return JSON changelog entries. + +1. Identify user-visible changes only +2. Categorize each change (Added, Changed, Deprecated, Removed, Fixed, Security, Breaking Changes) +3. Write entries starting with past-tense verb describing user impact +4. Omit categories with no entries +5. Return empty entries object for internal-only changes + +This matters. Be thorough but precise. + + + +- Added: New features, public APIs, user-facing capabilities +- Changed: Modified existing behavior +- Deprecated: Features scheduled for removal +- Removed: Deleted features or APIs +- Fixed: Bug corrections with observable impact +- Security: Vulnerability fixes +- Breaking Changes: API-incompatible modifications (use sparingly) + + + +- Start with past-tense verb (Added, Fixed, Implemented, Updated) +- Describe user-visible impact, not implementation +- Name the specific feature, option, or behavior +- Keep to 1-2 lines, no trailing periods + + + +Good: +- Added --dry-run flag to preview changes without applying them +- Fixed memory leak when processing large files +- Changed default timeout from 30s to 60s for slow connections + +Bad: +- **cli**: Added dry-run flag -> scope prefix redundant +- Added new feature. -> vague, has trailing period +- Refactored parser internals -> not user-visible + +Breaking Changes example: +- Removed legacy auth flow; users must re-authenticate with OAuth tokens + + + +Internal refactoring, code style changes, test-only modifications, minor doc updates, anything invisible to users. + + + +Return ONLY valid JSON. No markdown fences, no explanation. + +With entries: {"entries": {"Added": ["entry 1"], "Fixed": ["entry 2"]}} +No changelog-worthy changes: {"entries": {}} + diff --git a/packages/coding-agent/src/commit/prompts/changelog-user.md b/packages/coding-agent/src/commit/prompts/changelog-user.md new file mode 100644 index 000000000..7e6396466 --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/changelog-user.md @@ -0,0 +1,19 @@ + +Changelog: {{ changelog_path }} +{{#if is_package_changelog}}Scope: Package-level changelog. Omit package name prefix from entries.{{/if}} + +{{#if existing_entries}} + + +Already documented—skip these: +{{ existing_entries }} + +{{/if}} + + +{{ stat }} + + + +{{ diff }} + diff --git a/packages/coding-agent/src/commit/prompts/file-observer-system.md b/packages/coding-agent/src/commit/prompts/file-observer-system.md new file mode 100644 index 000000000..09a73d131 --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/file-observer-system.md @@ -0,0 +1,26 @@ +Expert code analyst extracting structured observations from diffs. + + +Extract factual observations from the diff. This matters—be precise. + +1. Use past-tense verb + specific target + optional purpose +2. Max 100 characters per observation +3. Consolidate related changes (e.g., "renamed 5 helper functions") +4. Return 1-5 observations only + + + +Include: functions, methods, types, API changes, behavior/logic changes, error handling, performance, security. + +Exclude: import reordering, whitespace/formatting, comment-only changes, debug statements. + + + +Plain list, no preamble, no summary, no markdown formatting. + +- added 'parse_config()' function for TOML configuration loading +- removed deprecated 'legacy_init()' and all callers +- changed 'Connection::new()' to accept '&Config' instead of individual params + + +Observations only. Classification happens in reduce phase. diff --git a/packages/coding-agent/src/commit/prompts/file-observer-user.md b/packages/coding-agent/src/commit/prompts/file-observer-user.md new file mode 100644 index 000000000..4f1590114 --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/file-observer-user.md @@ -0,0 +1,9 @@ + +{{ diff }} + +{{#if context_header}} + + +{{ context_header }} + +{{/if}} diff --git a/packages/coding-agent/src/commit/prompts/reduce-system.md b/packages/coding-agent/src/commit/prompts/reduce-system.md new file mode 100644 index 000000000..3c8c06d00 --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/reduce-system.md @@ -0,0 +1,60 @@ +You are a senior engineer synthesizing file-level observations into a conventional commit analysis. + + +Given map-phase observations from analyzed files, produce a unified commit classification with changelog metadata. + + + +Determine: +1. TYPE: Single classification for entire commit +2. SCOPE: Primary component (null if multi-component) +3. DETAILS: 3-4 summary points (max 6) +4. CHANGELOG: Metadata for user-visible changes + +Get this right. Accuracy matters. + + + +- Use component name if >=60% of changes target it +- Use null if spread across multiple components +- Use scope_candidates as primary source +- Valid scopes only: specific component names (api, parser, config, etc.) + + + +Each detail point: +- Past-tense verb start (added, fixed, moved, extracted) +- Under 120 characters, ends with period +- Group related cross-file changes + +Priority: user-visible behavior > performance/security > architecture > internal implementation + +changelog_category: Added | Changed | Fixed | Deprecated | Removed | Security +user_visible: true for features, user-facing bugs, breaking changes, security fixes + + + +Input observations: +- api/client.ts: added token refresh guard to prevent duplicate refreshes +- api/http.ts: introduced retry wrapper for 429 responses +- api/index.ts: updated exports for retry helper + +Output: +{ + "type": "fix", + "scope": "api", + "details": [ + { + "text": "Added token refresh guard to prevent duplicate refreshes.", + "changelog_category": "Fixed", + "user_visible": true + }, + { + "text": "Introduced retry wrapper for 429 responses.", + "changelog_category": "Fixed", + "user_visible": true + } + ], + "issue_refs": [] +} + diff --git a/packages/coding-agent/src/commit/prompts/reduce-user.md b/packages/coding-agent/src/commit/prompts/reduce-user.md new file mode 100644 index 000000000..cc4c524a8 --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/reduce-user.md @@ -0,0 +1,17 @@ +{{#if types_description}} + +{{ types_description }} + +{{/if}} + + +{{ observations }} + + + +{{ stat }} + + + +{{ scope_candidates }} + diff --git a/packages/coding-agent/src/commit/prompts/summary-system.md b/packages/coding-agent/src/commit/prompts/summary-system.md new file mode 100644 index 000000000..3791eda3e --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/summary-system.md @@ -0,0 +1,52 @@ +You are a commit message specialist generating precise, informative descriptions. + + +Output: ONLY the description after "{{ commit_type }}{{ scope_prefix }}:". +Constraint: {{ chars }} characters max, no trailing period, no type prefix in output. + + + +1. Start with lowercase past-tense verb (must differ from "{{ commit_type }}") +2. Name the specific subsystem/component affected +3. Include WHY when it clarifies intent +4. One focused concept per message + +Get this right. + + + +| Type | Use instead | +|----------|-------------------------------------------------| +| feat | added, introduced, implemented, enabled | +| fix | corrected, resolved, patched, addressed | +| refactor | restructured, reorganized, migrated, simplified | +| perf | optimized, reduced, eliminated, accelerated | +| docs | documented, clarified, expanded | +| build | upgraded, pinned, configured | +| chore | cleaned, removed, renamed, organized | + + + +feat | TLS encryption added to HTTP client for MITM prevention +-> added TLS support to prevent man-in-the-middle attacks + +refactor | Consolidated HTTP transport into unified builder pattern +-> migrated HTTP transport to unified builder API + +fix | Race condition in connection pool causing exhaustion under load +-> corrected race condition causing connection pool exhaustion + +perf | Batch processing optimized to reduce memory allocations +-> eliminated allocation overhead in batch processing + +build | Updated serde to fix CVE-2024-1234 +-> upgraded serde to 1.0.200 for CVE-2024-1234 + + + +comprehensive, various, several, improved, enhanced, quickly, simply, basically, this change, this commit, now + + + +Output the description text only. Include motivation, name specifics, stay focused. + diff --git a/packages/coding-agent/src/commit/prompts/summary-user.md b/packages/coding-agent/src/commit/prompts/summary-user.md new file mode 100644 index 000000000..61358e152 --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/summary-user.md @@ -0,0 +1,13 @@ +{{#if user_context}} + +{{ user_context }} + +{{/if}} + + +{{ details }} + + + +{{ stat }} + diff --git a/packages/coding-agent/src/commit/prompts/types-description.md b/packages/coding-agent/src/commit/prompts/types-description.md new file mode 100644 index 000000000..33a46bf9f --- /dev/null +++ b/packages/coding-agent/src/commit/prompts/types-description.md @@ -0,0 +1,2 @@ +Types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert. +Format: (): with past-tense summary. diff --git a/packages/coding-agent/src/commit/types.ts b/packages/coding-agent/src/commit/types.ts new file mode 100644 index 000000000..d7ecd7a62 --- /dev/null +++ b/packages/coding-agent/src/commit/types.ts @@ -0,0 +1,91 @@ +/** + * Types for the omp commit pipeline. + */ + +export type CommitType = + | "feat" + | "fix" + | "refactor" + | "perf" + | "docs" + | "test" + | "build" + | "ci" + | "chore" + | "style" + | "revert"; + +export type ChangelogCategory = + | "Breaking Changes" + | "Added" + | "Changed" + | "Deprecated" + | "Removed" + | "Fixed" + | "Security"; + +export interface CommitCommandArgs { + /** Push after commit */ + push: boolean; + /** Preview without committing */ + dryRun: boolean; + /** Skip changelog updates */ + noChangelog: boolean; + /** Additional user context for the model */ + context?: string; + /** Override the model selection */ + model?: string; +} + +export interface NumstatEntry { + path: string; + additions: number; + deletions: number; +} + +export interface ConventionalDetail { + text: string; + changelogCategory?: ChangelogCategory; + userVisible: boolean; +} + +export interface ConventionalAnalysis { + type: CommitType; + scope: string | null; + details: ConventionalDetail[]; + issueRefs: string[]; +} + +export interface CommitSummary { + summary: string; +} + +export interface FileObservation { + file: string; + observations: string[]; + additions: number; + deletions: number; +} + +export interface FileDiff { + filename: string; + content: string; + additions: number; + deletions: number; + isBinary: boolean; +} + +export interface ChangelogBoundary { + changelogPath: string; + files: string[]; +} + +export interface UnreleasedSection { + startLine: number; + endLine: number; + entries: Record; +} + +export interface ChangelogGenerationResult { + entries: Record; +} diff --git a/packages/coding-agent/src/commit/utils/exclusions.ts b/packages/coding-agent/src/commit/utils/exclusions.ts new file mode 100644 index 000000000..9e0f948ed --- /dev/null +++ b/packages/coding-agent/src/commit/utils/exclusions.ts @@ -0,0 +1,33 @@ +const EXCLUDED_FILES = [ + "Cargo.lock", + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "shrinkwrap.yaml", + "bun.lock", + "bun.lockb", + "deno.lock", + "composer.lock", + "Gemfile.lock", + "poetry.lock", + "Pipfile.lock", + "pdm.lock", + "uv.lock", + "go.sum", + "flake.lock", + "pubspec.lock", + "Podfile.lock", + "Packages.resolved", + "mix.lock", + "packages.lock.json", +]; + +export function isExcludedFile(path: string): boolean { + const lower = path.toLowerCase(); + return EXCLUDED_FILES.some((name) => lower.endsWith(name.toLowerCase())); +} + +export function filterExcludedFiles(files: T[]): T[] { + return files.filter((file) => !isExcludedFile(file.filename)); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 9737294f6..23276e608 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -21,6 +21,8 @@ import { selectSession } from "./cli/session-picker"; import { parseSetupArgs, printSetupHelp, runSetupCommand } from "./cli/setup-cli"; import { parseStatsArgs, printStatsHelp, runStatsCommand } from "./cli/stats-cli"; import { parseUpdateArgs, printUpdateHelp, runUpdateCommand } from "./cli/update-cli"; +import { runCommitCommand } from "./commit"; +import { parseCommitArgs, printCommitHelp } from "./commit/cli"; import { findConfigFile, getModelsPath, VERSION } from "./config"; import type { ModelRegistry } from "./config/model-registry"; import { parseModelPattern, parseModelString, resolveModelScope, type ScopedModel } from "./config/model-resolver"; @@ -532,6 +534,17 @@ export async function main(args: string[]) { return; } + // Handle commit subcommand + const commitCmd = parseCommitArgs(args); + if (commitCmd) { + if (args.includes("--help") || args.includes("-h")) { + printCommitHelp(); + return; + } + await runCommitCommand(commitCmd); + return; + } + const parsed = parseArgs(args); time("parseArgs"); await maybeAutoChdir(parsed);