feat(commit): added omp commit command for AI-powered conventional commits
- Added `omp commit` command for generating conventional commits with AI-powered analysis of staged changes. - Implemented conventional commit analysis pipeline with scope detection, changelog categorization, and summary generation. - Added map-reduce analysis pattern for efficiently processing large diffs using smaller models for file-level observations. - Implemented automatic changelog generation and updates to CHANGELOG.md files based on commit analysis. - Added git operations wrapper (ControlledGit) for managing staged files, diffs, commits, and push operations. - Implemented comprehensive validation for conventional commit format, scope naming, and changelog entries.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `omp commit` command to generate conventional commits with changelog updates
|
||||
|
||||
## [8.0.0] - 2026-01-23
|
||||
### Added
|
||||
|
||||
|
||||
@@ -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<Api>;
|
||||
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<ConventionalAnalysis> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { generateConventionalAnalysis } from "./conventional";
|
||||
export { extractScopeCandidates } from "./scope";
|
||||
export { generateSummary, stripTypePrefix } from "./summary";
|
||||
export { validateAnalysis, validateScope, validateSummary } from "./validation";
|
||||
@@ -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<string, number>();
|
||||
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<string, number>, 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;
|
||||
}
|
||||
@@ -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<Api>;
|
||||
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<CommitSummary> {
|
||||
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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<ChangelogBoundary[]> {
|
||||
const boundaries = new Map<string, string[]>();
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Api>;
|
||||
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<ChangelogGenerationResult> {
|
||||
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<string, string[]>): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [category, values] of Object.entries(entries)) {
|
||||
const seen = new Set<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<Api>;
|
||||
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<string[]> {
|
||||
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<string, string[]> };
|
||||
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, string[]>): 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<string, string[]> },
|
||||
entries: Record<string, string[]>,
|
||||
): 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<string, string[]>,
|
||||
incoming: Record<string, string[]>,
|
||||
): Record<string, string[]> {
|
||||
const merged: Record<string, string[]> = { ...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, string[]>): 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;
|
||||
}
|
||||
@@ -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<string, string[]> = {};
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import chalk from "chalk";
|
||||
import type { CommitCommandArgs } from "./types";
|
||||
|
||||
const FLAG_ALIASES = new Map<string, string>([
|
||||
["-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`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<NumstatEntry[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
const result = await stageFiles(this.cwd, []);
|
||||
this.ensureSuccess(result, "git add -A");
|
||||
}
|
||||
|
||||
async stageFiles(files: string[]): Promise<void> {
|
||||
const result = await stageFiles(this.cwd, files);
|
||||
this.ensureSuccess(result, "git add");
|
||||
}
|
||||
|
||||
async commit(message: string): Promise<void> {
|
||||
const result = await commit(this.cwd, message);
|
||||
this.ensureSuccess(result, "git commit");
|
||||
}
|
||||
|
||||
async push(): Promise<void> {
|
||||
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"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { $ } from "bun";
|
||||
|
||||
interface GitResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function runGitCommand(cwd: string, args: string[]): Promise<GitResult> {
|
||||
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<GitResult> {
|
||||
const args = files.length === 0 ? ["add", "-A"] : ["add", "--", ...files];
|
||||
return runGitCommand(cwd, args);
|
||||
}
|
||||
|
||||
export async function push(cwd: string): Promise<GitResult> {
|
||||
return runGitCommand(cwd, ["push"]);
|
||||
}
|
||||
|
||||
export async function commit(cwd: string, message: string): Promise<GitResult> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Entry points for the omp commit command.
|
||||
*/
|
||||
|
||||
export { runCommitCommand } from "./pipeline";
|
||||
@@ -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<Api>;
|
||||
apiKey: string;
|
||||
smolModel: Model<Api>;
|
||||
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<ConventionalAnalysis> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<Api>;
|
||||
apiKey: string;
|
||||
files: FileDiff[];
|
||||
}
|
||||
|
||||
export async function runMapPhase({ model, apiKey, files }: MapPhaseInput): Promise<FileObservation[]> {
|
||||
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<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
worker: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(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<T>(fn: () => Promise<T>, attempts: number): Promise<T> {
|
||||
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;
|
||||
}
|
||||
@@ -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<Api>;
|
||||
apiKey: string;
|
||||
observations: FileObservation[];
|
||||
stat: string;
|
||||
scopeCandidates: string;
|
||||
typesDescription?: string;
|
||||
}
|
||||
|
||||
export async function runReducePhase({
|
||||
model,
|
||||
apiKey,
|
||||
observations,
|
||||
stat,
|
||||
scopeCandidates,
|
||||
typesDescription,
|
||||
}: ReducePhaseInput): Promise<ConventionalAnalysis> {
|
||||
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();
|
||||
}
|
||||
@@ -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)`;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<Api>;
|
||||
primaryApiKey: string;
|
||||
smolModel: Model<Api>;
|
||||
smolApiKey: string;
|
||||
}): Promise<ConventionalAnalysis> {
|
||||
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<Api>;
|
||||
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<Api>[];
|
||||
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
|
||||
},
|
||||
): Promise<{ model: Model<Api>; 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<Api>[];
|
||||
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
|
||||
},
|
||||
fallbackModel: Model<Api>,
|
||||
fallbackApiKey: string,
|
||||
): Promise<{ model: Model<Api>; 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<Api>[]): Model<Api> | undefined {
|
||||
const configured = settingsManager.getModelRole("default");
|
||||
if (!configured) return available[0];
|
||||
return resolveModelFromString(configured, available) ?? available[0];
|
||||
}
|
||||
|
||||
function resolveModelFromString(value: string, available: Model<Api>[]): Model<Api> | 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`);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<context>
|
||||
You are a senior release engineer who writes precise, changelog-ready commit classifications. Your output feeds directly into automated release tooling.
|
||||
</context>
|
||||
|
||||
<instructions>
|
||||
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 <common_scopes> 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.
|
||||
</instructions>
|
||||
|
||||
<output_format>
|
||||
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": []
|
||||
}
|
||||
</output_format>
|
||||
|
||||
<examples>
|
||||
<example name="feature-with-api">
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
</example>
|
||||
|
||||
<example name="internal-refactor">
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
</example>
|
||||
|
||||
<example name="bug-fix">
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
</example>
|
||||
|
||||
<example name="minimal-chore">
|
||||
{
|
||||
"type": "chore",
|
||||
"scope": "deps",
|
||||
"details": [],
|
||||
"issue_refs": []
|
||||
}
|
||||
</example>
|
||||
</examples>
|
||||
|
||||
Be thorough. This matters.
|
||||
@@ -0,0 +1,41 @@
|
||||
{{#if context_files}}
|
||||
<project_context>
|
||||
{{#each context_files}}
|
||||
<file path="{{ path }}">
|
||||
{{ content }}
|
||||
</file>
|
||||
{{/each}}
|
||||
</project_context>
|
||||
{{/if}}
|
||||
{{#if user_context}}
|
||||
<user_context>
|
||||
{{ user_context }}
|
||||
</user_context>
|
||||
{{/if}}
|
||||
{{#if types_description}}
|
||||
<commit_types>
|
||||
{{ types_description }}
|
||||
</commit_types>
|
||||
{{/if}}
|
||||
|
||||
<diff_statistics>
|
||||
{{ stat }}
|
||||
</diff_statistics>
|
||||
|
||||
<scope_candidates>
|
||||
{{ scope_candidates }}
|
||||
</scope_candidates>
|
||||
{{#if common_scopes}}
|
||||
<common_scopes>
|
||||
{{ common_scopes }}
|
||||
</common_scopes>
|
||||
{{/if}}
|
||||
{{#if recent_commits}}
|
||||
<style_patterns>
|
||||
{{ recent_commits }}
|
||||
</style_patterns>
|
||||
{{/if}}
|
||||
|
||||
<diff>
|
||||
{{ diff }}
|
||||
</diff>
|
||||
@@ -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.
|
||||
|
||||
<instructions>
|
||||
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.
|
||||
</instructions>
|
||||
|
||||
<categories>
|
||||
- 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)
|
||||
</categories>
|
||||
|
||||
<entry_format>
|
||||
- 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
|
||||
</entry_format>
|
||||
|
||||
<examples>
|
||||
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
|
||||
</examples>
|
||||
|
||||
<exclude>
|
||||
Internal refactoring, code style changes, test-only modifications, minor doc updates, anything invisible to users.
|
||||
</exclude>
|
||||
|
||||
<output_format>
|
||||
Return ONLY valid JSON. No markdown fences, no explanation.
|
||||
|
||||
With entries: {"entries": {"Added": ["entry 1"], "Fixed": ["entry 2"]}}
|
||||
No changelog-worthy changes: {"entries": {}}
|
||||
</output_format>
|
||||
@@ -0,0 +1,19 @@
|
||||
<context>
|
||||
Changelog: {{ changelog_path }}
|
||||
{{#if is_package_changelog}}Scope: Package-level changelog. Omit package name prefix from entries.{{/if}}
|
||||
</context>
|
||||
{{#if existing_entries}}
|
||||
|
||||
<existing_entries>
|
||||
Already documented—skip these:
|
||||
{{ existing_entries }}
|
||||
</existing_entries>
|
||||
{{/if}}
|
||||
|
||||
<diff_summary>
|
||||
{{ stat }}
|
||||
</diff_summary>
|
||||
|
||||
<diff>
|
||||
{{ diff }}
|
||||
</diff>
|
||||
@@ -0,0 +1,26 @@
|
||||
<role>Expert code analyst extracting structured observations from diffs.</role>
|
||||
|
||||
<instructions>
|
||||
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
|
||||
</instructions>
|
||||
|
||||
<scope>
|
||||
Include: functions, methods, types, API changes, behavior/logic changes, error handling, performance, security.
|
||||
|
||||
Exclude: import reordering, whitespace/formatting, comment-only changes, debug statements.
|
||||
</scope>
|
||||
|
||||
<output_format>
|
||||
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
|
||||
</output_format>
|
||||
|
||||
Observations only. Classification happens in reduce phase.
|
||||
@@ -0,0 +1,9 @@
|
||||
<file path="{{ filename }}">
|
||||
{{ diff }}
|
||||
</file>
|
||||
{{#if context_header}}
|
||||
|
||||
<related_files>
|
||||
{{ context_header }}
|
||||
</related_files>
|
||||
{{/if}}
|
||||
@@ -0,0 +1,60 @@
|
||||
You are a senior engineer synthesizing file-level observations into a conventional commit analysis.
|
||||
|
||||
<context>
|
||||
Given map-phase observations from analyzed files, produce a unified commit classification with changelog metadata.
|
||||
</context>
|
||||
|
||||
<instructions>
|
||||
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.
|
||||
</instructions>
|
||||
|
||||
<scope_rules>
|
||||
- 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.)
|
||||
</scope_rules>
|
||||
|
||||
<output_format>
|
||||
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
|
||||
</output_format>
|
||||
|
||||
<example>
|
||||
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": []
|
||||
}
|
||||
</example>
|
||||
@@ -0,0 +1,17 @@
|
||||
{{#if types_description}}
|
||||
<type_definitions>
|
||||
{{ types_description }}
|
||||
</type_definitions>
|
||||
{{/if}}
|
||||
|
||||
<observations>
|
||||
{{ observations }}
|
||||
</observations>
|
||||
|
||||
<diff_statistics>
|
||||
{{ stat }}
|
||||
</diff_statistics>
|
||||
|
||||
<scope_candidates>
|
||||
{{ scope_candidates }}
|
||||
</scope_candidates>
|
||||
@@ -0,0 +1,52 @@
|
||||
You are a commit message specialist generating precise, informative descriptions.
|
||||
|
||||
<context>
|
||||
Output: ONLY the description after "{{ commit_type }}{{ scope_prefix }}:".
|
||||
Constraint: {{ chars }} characters max, no trailing period, no type prefix in output.
|
||||
</context>
|
||||
|
||||
<instructions>
|
||||
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.
|
||||
</instructions>
|
||||
|
||||
<verb_reference>
|
||||
| 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 |
|
||||
</verb_reference>
|
||||
|
||||
<examples>
|
||||
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
|
||||
</examples>
|
||||
|
||||
<banned_words>
|
||||
comprehensive, various, several, improved, enhanced, quickly, simply, basically, this change, this commit, now
|
||||
</banned_words>
|
||||
|
||||
<output_format>
|
||||
Output the description text only. Include motivation, name specifics, stay focused.
|
||||
</output_format>
|
||||
@@ -0,0 +1,13 @@
|
||||
{{#if user_context}}
|
||||
<user_context>
|
||||
{{ user_context }}
|
||||
</user_context>
|
||||
{{/if}}
|
||||
|
||||
<detail_points>
|
||||
{{ details }}
|
||||
</detail_points>
|
||||
|
||||
<diff_stat>
|
||||
{{ stat }}
|
||||
</diff_stat>
|
||||
@@ -0,0 +1,2 @@
|
||||
Types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert.
|
||||
Format: <type>(<scope>): <summary> with past-tense summary.
|
||||
@@ -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<string, string[]>;
|
||||
}
|
||||
|
||||
export interface ChangelogGenerationResult {
|
||||
entries: Record<string, string[]>;
|
||||
}
|
||||
@@ -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<T extends { filename: string }>(files: T[]): T[] {
|
||||
return files.filter((file) => !isExcludedFile(file.filename));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user