fix(coding-agent/commit): Hardened changelog schemas and shared categories (#149)

- Added a shared CHANGELOG_CATEGORIES constant and reused it across changelog tooling.
- Validated changelog entry and deletion payloads to require arrays and flag invalid categories.
- Exported the changelog tool schema for generator validation and referenced it during parsing.

Co-authored-by: Can Bölük <can1357@users.noreply.github.com>
This commit is contained in:
Julia
2026-02-24 02:30:34 +00:00
committed by GitHub
parent bb7f288489
commit 2ff8f25d31
5 changed files with 59 additions and 28 deletions
@@ -1,16 +1,25 @@
import { Type } from "@sinclair/typebox";
import { type TSchema, Type } from "@sinclair/typebox";
import type { CommitAgentState } from "../../../commit/agentic/state";
import type { ChangelogCategory } from "../../../commit/types";
import { CHANGELOG_CATEGORIES, type ChangelogCategory } from "../../../commit/types";
import type { CustomTool } from "../../../extensibility/custom-tools/types";
const changelogEntryProperties = CHANGELOG_CATEGORIES.reduce<Record<ChangelogCategory, TSchema>>(
(acc, category) => {
acc[category] = Type.Optional(Type.Array(Type.String()));
return acc;
},
{} as Record<ChangelogCategory, TSchema>,
);
const changelogEntriesSchema = Type.Object(changelogEntryProperties);
const changelogDeletionsSchema = Type.Object(changelogEntryProperties, {
description: "Entries to remove from existing changelog sections (case-insensitive match)",
});
const changelogEntrySchema = Type.Object({
path: Type.String(),
entries: Type.Record(Type.String(), Type.Array(Type.String())),
deletions: Type.Optional(
Type.Record(Type.String(), Type.Array(Type.String()), {
description: "Entries to remove from existing changelog sections (case-insensitive match)",
}),
),
entries: changelogEntriesSchema,
deletions: Type.Optional(changelogDeletionsSchema),
});
const proposeChangelogSchema = Type.Object({
@@ -23,15 +32,7 @@ interface ChangelogResponse {
warnings: string[];
}
const allowedCategories = new Set<ChangelogCategory>([
"Breaking Changes",
"Added",
"Changed",
"Deprecated",
"Removed",
"Fixed",
"Security",
]);
const allowedCategories = new Set<ChangelogCategory>(CHANGELOG_CATEGORIES);
export function createProposeChangelogTool(
state: CommitAgentState,
@@ -50,11 +51,16 @@ export function createProposeChangelogTool(
const normalized = params.entries.map(entry => {
const cleaned: Record<string, string[]> = {};
for (const [category, values] of Object.entries(entry.entries ?? {})) {
const entries = entry.entries as Record<string, string[]>;
for (const [category, values] of Object.entries(entries)) {
if (!allowedCategories.has(category as ChangelogCategory)) {
errors.push(`Unknown changelog category for ${entry.path}: ${category}`);
continue;
}
if (!Array.isArray(values)) {
errors.push(`Invalid changelog entries for ${entry.path}: ${category}`);
continue;
}
const items = values.map(value => value.trim().replace(/\.$/, "")).filter(value => value.length > 0);
if (items.length > 0) {
cleaned[category] = Array.from(new Set(items));
@@ -64,11 +70,16 @@ export function createProposeChangelogTool(
let cleanedDeletions: Record<string, string[]> | undefined;
if (entry.deletions) {
cleanedDeletions = {};
for (const [category, values] of Object.entries(entry.deletions)) {
const deletions = entry.deletions as Record<string, string[]>;
for (const [category, values] of Object.entries(deletions)) {
if (!allowedCategories.has(category as ChangelogCategory)) {
errors.push(`Unknown deletion category for ${entry.path}: ${category}`);
continue;
}
if (!Array.isArray(values)) {
errors.push(`Invalid deletion entries for ${entry.path}: ${category}`);
continue;
}
const items = values.map(value => value.trim()).filter(value => value.length > 0);
if (items.length > 0) {
cleanedDeletions[category] = Array.from(new Set(items));
@@ -1,17 +1,27 @@
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import { type TSchema, Type } from "@sinclair/typebox";
import changelogSystemPrompt from "../../commit/prompts/changelog-system.md" with { type: "text" };
import changelogUserPrompt from "../../commit/prompts/changelog-user.md" with { type: "text" };
import type { ChangelogGenerationResult } from "../../commit/types";
import { CHANGELOG_CATEGORIES, type ChangelogCategory, type ChangelogGenerationResult } from "../../commit/types";
import { renderPromptTemplate } from "../../config/prompt-templates";
import { extractTextContent, extractToolCall, parseJsonPayload } from "../utils";
const ChangelogTool = {
const changelogEntryProperties = CHANGELOG_CATEGORIES.reduce<Record<ChangelogCategory, TSchema>>(
(acc, category) => {
acc[category] = Type.Optional(Type.Array(Type.String()));
return acc;
},
{} as Record<ChangelogCategory, TSchema>,
);
const changelogEntriesSchema = Type.Object(changelogEntryProperties);
export 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())),
entries: changelogEntriesSchema,
}),
};
@@ -46,7 +56,7 @@ export async function generateChangelogEntries({
{
systemPrompt: renderPromptTemplate(changelogSystemPrompt),
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
tools: [ChangelogTool],
tools: [changelogTool],
},
{ apiKey, maxTokens: 1200 },
);
@@ -58,7 +68,7 @@ export async function generateChangelogEntries({
function parseChangelogResponse(message: AssistantMessage): ChangelogGenerationResult {
const toolCall = extractToolCall(message, "create_changelog_entries");
if (toolCall) {
const parsed = validateToolCall([ChangelogTool], toolCall) as ChangelogGenerationResult;
const parsed = validateToolCall([changelogTool], toolCall) as ChangelogGenerationResult;
return { entries: parsed.entries ?? {} };
}
@@ -2,11 +2,12 @@ import * as path from "node:path";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import type { ControlledGit } from "../../commit/git";
import { CHANGELOG_CATEGORIES } from "../../commit/types";
import { detectChangelogBoundaries } from "./detect";
import { generateChangelogEntries } from "./generate";
import { parseUnreleasedSection } from "./parse";
const CHANGELOG_SECTIONS = ["Breaking Changes", "Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"];
const CHANGELOG_SECTIONS = CHANGELOG_CATEGORIES;
const DEFAULT_MAX_DIFF_CHARS = 120_000;
+10 -1
View File
@@ -1,7 +1,6 @@
/**
* Types for the omp commit pipeline.
*/
export type CommitType =
| "feat"
| "fix"
@@ -24,6 +23,16 @@ export type ChangelogCategory =
| "Fixed"
| "Security";
export const CHANGELOG_CATEGORIES: ChangelogCategory[] = [
"Breaking Changes",
"Added",
"Changed",
"Deprecated",
"Removed",
"Fixed",
"Security",
];
export interface CommitCommandArgs {
/** Push after commit */
push: boolean;