Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 68 additions & 38 deletions src/ai/aiSummary.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { CommitInfo, DiffSummary } from '../git/gitDiff.js';
import { createOpenAiLikeClient, shouldUseLlmGateway, type OpenAiLikeClient } from './openAIConfig.js';
import type { CommitInfo, DiffSummary } from "../git/gitDiff.js";
import {
createOpenAiLikeClient,
shouldUseLlmGateway,
type OpenAiLikeClient,
} from "./openAIConfig.js";

/**
* Cap on unified-diff characters sent to the LLM (only the diff body; preamble is extra).
Expand All @@ -9,7 +13,11 @@ const DEFAULT_LLM_MAX_DIFF_CHARS = 120_000;

/** Resolve max unified-diff characters for the LLM path. CLI wins, then env, then default. */
export function resolveLlmMaxDiffChars(cliOverride?: number): number {
if (cliOverride !== undefined && Number.isFinite(cliOverride) && cliOverride > 0) {
if (
cliOverride !== undefined &&
Number.isFinite(cliOverride) &&
cliOverride > 0
) {
return Math.trunc(cliOverride);
}
const raw = process.env.LLM_MAX_DIFF_CHARS?.trim();
Expand All @@ -22,7 +30,10 @@ export function resolveLlmMaxDiffChars(cliOverride?: number): number {
return DEFAULT_LLM_MAX_DIFF_CHARS;
}

export function truncateUnifiedDiffForLlm(diffText: string, maxChars: number): string {
export function truncateUnifiedDiffForLlm(
diffText: string,
maxChars: number,
): string {
if (diffText.length <= maxChars) {
return diffText;
}
Expand All @@ -41,9 +52,9 @@ If the user message includes a Team line, use that exact team name in the summar

/** Thrown when no LLM gateway is configured and no `openAiClientProvider` was passed. */
export const LLM_GATEWAY_REQUIRED_MESSAGE =
'No LLM gateway configured. Set OPENAI_API_KEY or LLM_API_KEY, and/or LLM_BASE_URL or OPENAI_BASE_URL, ' +
'and/or JSON in OPENAI_DEFAULT_HEADERS or LLM_DEFAULT_HEADERS. ' +
'Alternatively pass openAiClientProvider to generateSummary or summarizeGitDiff.';
"No LLM gateway configured. Set OPENAI_API_KEY or LLM_API_KEY, and/or LLM_BASE_URL or OPENAI_BASE_URL, " +
"and/or JSON in OPENAI_DEFAULT_HEADERS or LLM_DEFAULT_HEADERS. " +
"Alternatively pass openAiClientProvider to generateSummary or summarizeGitDiff.";

export type SummarizeFlags = {
/** Start ref for the diff. */
Expand All @@ -68,44 +79,55 @@ export async function generateSummary(
commits: CommitInfo[],
flags: SummarizeFlags,
openAiClientProvider?: OpenAiClientProvider,
diffSummary?: DiffSummary
diffSummary?: DiffSummary,
): Promise<string> {
if (!shouldUseLlmGateway() && openAiClientProvider === undefined) {
throw new Error(LLM_GATEWAY_REQUIRED_MESSAGE);
}

const maxDiffChars = resolveLlmMaxDiffChars(flags.maxDiffChars);
const diffForLlm = truncateUnifiedDiffForLlm(diffText, maxDiffChars);
const userContent = buildOpenAiUserContent(flags, commits, fileNames, diffForLlm, diffSummary);
const userContent = buildOpenAiUserContent(
flags,
commits,
fileNames,
diffForLlm,
diffSummary,
);
return callOpenAi(
userContent,
flags.model ?? 'gpt-4o-mini',
flags.model ?? "gpt-4o-mini",
flags.systemPrompt ?? DEFAULT_GIT_DIFF_SYSTEM_PROMPT,
openAiClientProvider ??
/* istanbul ignore next */ (async (): Promise<OpenAiLikeClient> => createOpenAiLikeClient())
/* istanbul ignore next */ (async (): Promise<OpenAiLikeClient> =>
createOpenAiLikeClient()),
);
}

function formatRegexFilterLines(flags: SummarizeFlags): string {
const includes = (flags.commitMessageIncludeRegexes ?? []).map((s) => s.trim()).filter(Boolean);
const excludes = (flags.commitMessageExcludeRegexes ?? []).map((s) => s.trim()).filter(Boolean);
const includes = (flags.commitMessageIncludeRegexes ?? [])
.map((s) => s.trim())
.filter(Boolean);
const excludes = (flags.commitMessageExcludeRegexes ?? [])
.map((s) => s.trim())
.filter(Boolean);

const incLine =
includes.length > 0
? `Commit message include regexes (OR): ${includes.map((r) => JSON.stringify(r)).join(', ')}\n`
: '';
? `Commit message include regexes (OR): ${includes.map((r) => JSON.stringify(r)).join(", ")}\n`
: "";
const excLine =
excludes.length > 0
? `Commit message exclude regexes: ${excludes.map((r) => JSON.stringify(r)).join(', ')}\n`
: '';
? `Commit message exclude regexes: ${excludes.map((r) => JSON.stringify(r)).join(", ")}\n`
: "";

if (!incLine && !excLine) {
return 'Commit message filters: none.\nGit context shape: single unified diff for the full ref range.\n';
return "Commit message filters: none.\nGit context shape: single unified diff for the full ref range.\n";
}

return (
`${incLine}${excLine}` +
'Git context shape: concatenated per-commit unified patches for commits that pass the message filters.\n'
"Git context shape: concatenated per-commit unified patches for commits that pass the message filters.\n"
);
}

Expand All @@ -114,37 +136,43 @@ function buildOpenAiUserContent(
commits: CommitInfo[],
fileNames: string[],
diffText: string,
diffSummary?: DiffSummary
diffSummary?: DiffSummary,
): string {
const from = flags.from;
const to = flags.to ?? 'HEAD';
const to = flags.to ?? "HEAD";
const team = flags.team?.trim();
const ts = new Date().toISOString();
const teamLine = team ? `Team: ${team}\n` : '';
const teamLine = team ? `Team: ${team}\n` : "";
const filterBlock = formatRegexFilterLines(flags);

const commitBlock =
commits.length > 0
? commits.map((c) => `- ${c.hash.slice(0, 7)} ${c.message.replace(/\r?\n/g, ' ')}`).join('\n')
: '- (no commits in range after filtering)';

const pathsBlock = fileNames.length > 0 ? fileNames.join('\n') : '(no paths in diff scope)';
? commits
.map(
(c) =>
`- ${c.hash.slice(0, 7)} ${c.message.replace(/\r?\n/g, " ")}`,
)
.join("\n")
: "- (no commits in range after filtering)";

const pathsBlock =
fileNames.length > 0 ? fileNames.join("\n") : "(no paths in diff scope)";
const structuredDiffSection = diffSummary
? `=== Structured git context (JSON summary) ===\n${JSON.stringify(diffSummary, null, 2)}\n\n`
: '';
: "";

return (
`${teamLine}` +
`Date: ${ts}\n\n` +
`Git refs: ${from}..${to}\n` +
filterBlock +
'\n' +
'=== Included commits (subject lines) ===\n' +
"\n" +
"=== Included commits (subject lines) ===\n" +
`${commitBlock}\n\n` +
'=== Changed paths ===\n' +
"=== Changed paths ===\n" +
`${pathsBlock}\n\n` +
structuredDiffSection +
'=== Git context (unified diff(s); patches may be truncated with an explicit marker) ===\n' +
"=== Git context (unified diff(s); patches may be truncated with an explicit marker) ===\n" +
diffText
);
}
Expand All @@ -153,22 +181,24 @@ async function callOpenAi(
userContent: string,
model: string,
systemPrompt: string,
openAiClientProvider: OpenAiClientProvider
openAiClientProvider: OpenAiClientProvider,
): Promise<string> {
const client = await openAiClientProvider();
const maxTokensRaw = process.env.LLM_MAX_TOKENS ?? process.env.OPENAI_MAX_TOKENS;
const parsed = maxTokensRaw !== undefined ? Number.parseInt(maxTokensRaw, 10) : 4000;
const maxTokensRaw =
process.env.LLM_MAX_TOKENS ?? process.env.OPENAI_MAX_TOKENS;
const parsed =
maxTokensRaw !== undefined ? Number.parseInt(maxTokensRaw, 10) : 4000;
const maxTokens = Number.isFinite(parsed) && parsed > 0 ? parsed : 4000;

const response = await client.chat.completions.create({
model,
messages: [
{
role: 'system',
role: "system",
content: systemPrompt,
},
{
role: 'user',
role: "user",
content: userContent,
},
],
Expand All @@ -182,6 +212,6 @@ async function callOpenAi(
choices?: Array<{ message?: { content?: string } }>;
};

const text = typedResponse.choices?.[0]?.message?.content?.trim() ?? '';
return text.length > 0 ? text : 'No summary generated by OpenAI.';
const text = typedResponse.choices?.[0]?.message?.content?.trim() ?? "";
return text.length > 0 ? text : "No summary generated by OpenAI.";
}
48 changes: 34 additions & 14 deletions src/ai/openAIConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,28 @@

/** `LLM_BASE_URL` overrides `OPENAI_BASE_URL` when set. */
export function resolveLlmBaseUrl(): string | undefined {
return process.env.LLM_BASE_URL?.trim() ?? process.env.OPENAI_BASE_URL?.trim();
return (
process.env.LLM_BASE_URL?.trim() ?? process.env.OPENAI_BASE_URL?.trim()
);
}

function parseHeaderJsonObject(raw: string | undefined): Record<string, string> {
function parseHeaderJsonObject(
raw: string | undefined,
): Record<string, string> {
const trimmed = raw?.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed) as unknown;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
return {};
}
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'string' && value.length > 0) {
if (typeof value === "string" && value.length > 0) {
out[key] = value;
}
}
Expand All @@ -31,15 +39,19 @@ function parseHeaderJsonObject(raw: string | undefined): Record<string, string>
/**
* Merged default headers: `OPENAI_DEFAULT_HEADERS` first, then `LLM_DEFAULT_HEADERS` overrides.
*/
export function parseLlmDefaultHeadersFromEnv(): Record<string, string> | undefined {
export function parseLlmDefaultHeadersFromEnv():
| Record<string, string>
| undefined {
const base = parseHeaderJsonObject(process.env.OPENAI_DEFAULT_HEADERS);
const override = parseHeaderJsonObject(process.env.LLM_DEFAULT_HEADERS);
const merged = { ...base, ...override };
return Object.keys(merged).length > 0 ? merged : undefined;
}

function findAuthorizationHeaderName(headers: Record<string, string>): string | undefined {
return Object.keys(headers).find((k) => k.toLowerCase() === 'authorization');
function findAuthorizationHeaderName(
headers: Record<string, string>,
): string | undefined {
return Object.keys(headers).find((k) => k.toLowerCase() === "authorization");
}

/** Strip a single `Bearer <token>` prefix; otherwise return the trimmed value. */
Expand All @@ -56,7 +68,9 @@ function stripBearerPrefix(value: string): string {
* (`param: api_key`). When no `LLM_API_KEY` / `OPENAI_API_KEY` is set, promote recognizable
* tokens from `Authorization` into `apiKey` and drop that header from `defaultHeaders`.
*/
export function splitPromotableAuthorizationFromHeaders(headers: Record<string, string>): {
export function splitPromotableAuthorizationFromHeaders(
headers: Record<string, string>,
): {
defaultHeaders: Record<string, string>;
apiKeyFromAuthHeader?: string;
} {
Expand All @@ -80,7 +94,8 @@ export function splitPromotableAuthorizationFromHeaders(headers: Record<string,
}

export function shouldUseLlmGateway(): boolean {
const apiKey = process.env.LLM_API_KEY?.trim() ?? process.env.OPENAI_API_KEY?.trim();
const apiKey =
process.env.LLM_API_KEY?.trim() ?? process.env.OPENAI_API_KEY?.trim();
if (apiKey) return true;
if (resolveLlmBaseUrl()) return true;
const jsonHeaders = parseLlmDefaultHeadersFromEnv();
Expand All @@ -106,7 +121,8 @@ export type OpenAiLikeClientInit = {
export function resolveOpenAiLikeClientInit(): OpenAiLikeClientInit {
const baseURL = resolveLlmBaseUrl();
const mergedHeaders = parseLlmDefaultHeadersFromEnv() ?? {};
const envApiKey = process.env.LLM_API_KEY?.trim() ?? process.env.OPENAI_API_KEY?.trim() ?? '';
const envApiKey =
process.env.LLM_API_KEY?.trim() ?? process.env.OPENAI_API_KEY?.trim() ?? "";

let defaultHeaders: Record<string, string> | undefined;
let apiKey = envApiKey;
Expand All @@ -116,20 +132,24 @@ export function resolveOpenAiLikeClientInit(): OpenAiLikeClientInit {
if (split.apiKeyFromAuthHeader) {
apiKey = split.apiKeyFromAuthHeader;
}
defaultHeaders = Object.keys(split.defaultHeaders).length > 0 ? split.defaultHeaders : undefined;
defaultHeaders =
Object.keys(split.defaultHeaders).length > 0
? split.defaultHeaders
: undefined;
} else {
defaultHeaders = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined;
defaultHeaders =
Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined;
}

return {
apiKey: apiKey.length > 0 ? apiKey : 'unused',
apiKey: apiKey.length > 0 ? apiKey : "unused",
...(baseURL ? { baseURL } : {}),
...(defaultHeaders ? { defaultHeaders } : {}),
};
}

/** Build options for `new OpenAI(...)` (official OpenAI Node SDK). */
export async function createOpenAiLikeClient(): Promise<OpenAiLikeClient> {
const { default: OpenAI } = await import('openai');
const { default: OpenAI } = await import("openai");
return new OpenAI(resolveOpenAiLikeClientInit()) as OpenAiLikeClient;
}
52 changes: 52 additions & 0 deletions src/git/commitMessageFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { CommitInfo } from "./diffTypes.js";

function compileRegex(pattern: string, label: string): RegExp {
try {
return new RegExp(pattern, "i");
} catch {
throw new Error(
`Invalid ${label} regular expression: ${JSON.stringify(pattern)}`,
);
}
}

function commitMessagePassesFilters(
message: string,
includeRes: RegExp[],
excludeRes: RegExp[],
): boolean {
for (const ex of excludeRes) {
if (ex.test(message)) return false;
}
if (includeRes.length > 0 && !includeRes.some((r) => r.test(message)))
return false;
return true;
}

/**
* Filter commits by message. Excludes are applied first; then if `includePatterns` is non-empty,
* the message must match at least one include pattern.
*/
export function filterCommitsByMessageRegexes(
commits: CommitInfo[],
includePatterns?: string[],
excludePatterns?: string[],
): CommitInfo[] {
const includes = (includePatterns ?? [])
.map((p) => p.trim())
.filter((p) => p.length > 0);
const excludes = (excludePatterns ?? [])
.map((p) => p.trim())
.filter((p) => p.length > 0);

const includeRes = includes.map((p, i) =>
compileRegex(p, `commit message include pattern[${i}]`),
);
const excludeRes = excludes.map((p, i) =>
compileRegex(p, `commit message exclude pattern[${i}]`),
);

return commits.filter((c) =>
commitMessagePassesFilters(c.message, includeRes, excludeRes),
);
}
Loading
Loading