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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user