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
20 changes: 20 additions & 0 deletions src/ai/aiConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Cap on unified-diff characters sent to the LLM (only the diff body; preamble is extra).
* Tuned for ~128k-token context models; override with `LLM_MAX_DIFF_CHARS` or `maxDiffChars` in options.
*/
export const DEFAULT_LLM_MAX_DIFF_CHARS = 120_000;

/** Default system prompt when summarizing a git diff for any repository. */
export const DEFAULT_GIT_DIFF_SYSTEM_PROMPT = `You are a senior software engineer helping developers understand code and configuration changes from the git context they supplied.
You receive: commit subject lines (when available), changed file paths, and unified git patch(es)—either one range diff or concatenated per-commit patches, depending on how the diff was produced. Patches may be truncated mid-section with an explicit marker—do not infer changes beyond visible lines.
Explain what changed in terms of behavior, APIs, data, configuration, security, and operational risk. Tie claims to the patch when possible.
Produce a concise, developer-focused summary in Markdown.
Use sections that fit the change (for example: Highlights, Breaking or risky changes, API / contract changes, Data & schema, Configuration & infra, Security & auth, Tests & quality). Omit empty sections.
Group related changes; do not list every individual file. When multiple commits appear in the context, briefly separate notable themes by commit when helpful.
If the user message includes a Team line, use that exact team name in the summary title (for example: "## <Team> – Change summary" or similar).`;

/** 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.";
64 changes: 20 additions & 44 deletions src/ai/aiSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import {
shouldUseLlmGateway,
type OpenAiLikeClient,
} from "./openAIConfig.js";

/**
* Cap on unified-diff characters sent to the LLM (only the diff body; preamble is extra).
* Tuned for ~128k-token context models; override with `LLM_MAX_DIFF_CHARS` or `maxDiffChars` in options.
*/
const DEFAULT_LLM_MAX_DIFF_CHARS = 120_000;
import {
DEFAULT_LLM_MAX_DIFF_CHARS,
DEFAULT_GIT_DIFF_SYSTEM_PROMPT,
LLM_GATEWAY_REQUIRED_MESSAGE,
} from "./aiConstants.js";
import type {
GenerateSummaryInput,
OpenAiClientProvider,
SummarizeFlags,
} from "./aiTypes.js";

/** Resolve max unified-diff characters for the LLM path. CLI wins, then env, then default. */
export function resolveLlmMaxDiffChars(cliOverride?: number): number {
Expand Down Expand Up @@ -41,46 +45,18 @@ export function truncateUnifiedDiffForLlm(
return diffText.slice(0, maxChars) + marker;
}

/** Default system prompt when summarizing a git diff for any repository. */
export const DEFAULT_GIT_DIFF_SYSTEM_PROMPT = `You are a senior software engineer helping developers understand code and configuration changes from the git context they supplied.
You receive: commit subject lines (when available), changed file paths, and unified git patch(es)—either one range diff or concatenated per-commit patches, depending on how the diff was produced. Patches may be truncated mid-section with an explicit marker—do not infer changes beyond visible lines.
Explain what changed in terms of behavior, APIs, data, configuration, security, and operational risk. Tie claims to the patch when possible.
Produce a concise, developer-focused summary in Markdown.
Use sections that fit the change (for example: Highlights, Breaking or risky changes, API / contract changes, Data & schema, Configuration & infra, Security & auth, Tests & quality). Omit empty sections.
Group related changes; do not list every individual file. When multiple commits appear in the context, briefly separate notable themes by commit when helpful.
If the user message includes a Team line, use that exact team name in the summary title (for example: "## <Team> – Change summary" or similar).`;

/** 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.";

export type SummarizeFlags = {
/** Start ref for the diff. */
from: string;
to?: string;
model?: string;
/** Optional team or squad label for the summary title and context. */
team?: string;
/** Max characters of unified diff sent to the LLM; see `resolveLlmMaxDiffChars`. */
maxDiffChars?: number;
/** When set, replaces {@link DEFAULT_GIT_DIFF_SYSTEM_PROMPT} for the chat completion. */
systemPrompt?: string;
commitMessageIncludeRegexes?: string[];
commitMessageExcludeRegexes?: string[];
};

type OpenAiClientProvider = () => Promise<OpenAiLikeClient>;

export async function generateSummary(
diffText: string,
fileNames: string[],
commits: CommitInfo[],
flags: SummarizeFlags,
openAiClientProvider?: OpenAiClientProvider,
diffSummary?: DiffSummary,
input: GenerateSummaryInput,
): Promise<string> {
const {
diffText,
fileNames,
commits,
flags,
openAiClientProvider,
diffSummary,
} = input;

if (!shouldUseLlmGateway() && openAiClientProvider === undefined) {
throw new Error(LLM_GATEWAY_REQUIRED_MESSAGE);
}
Expand Down
29 changes: 29 additions & 0 deletions src/ai/aiTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { CommitInfo, DiffSummary } from "../git/gitDiff.js";
import { type OpenAiLikeClient } from "./openAIConfig.js";

export type SummarizeFlags = {
/** Start ref for the diff. */
from: string;
to?: string;
model?: string;
/** Optional team or squad label for the summary title and context. */
team?: string;
/** Max characters of unified diff sent to the LLM; see `resolveLlmMaxDiffChars`. */
maxDiffChars?: number;
/** When set, replaces {@link DEFAULT_GIT_DIFF_SYSTEM_PROMPT} for the chat completion. */
systemPrompt?: string;
commitMessageIncludeRegexes?: string[];
commitMessageExcludeRegexes?: string[];
};

export type OpenAiClientProvider = () => Promise<OpenAiLikeClient>;

/** Input object for `generateSummary` (see `aiSummary.ts`). */
export type GenerateSummaryInput = {
diffText: string;
fileNames: string[];
commits: CommitInfo[];
flags: SummarizeFlags;
openAiClientProvider?: OpenAiClientProvider;
diffSummary?: DiffSummary;
};
22 changes: 13 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SimpleGit } from "simple-git";

import { generateSummary, type SummarizeFlags } from "./ai/aiSummary.js";
import { generateSummary } from "./ai/aiSummary.js";
import type { SummarizeFlags } from "./ai/aiTypes.js";
import type { OpenAiLikeClient } from "./ai/openAIConfig.js";
import {
createGitClient,
Expand Down Expand Up @@ -127,14 +128,14 @@ export async function summarizeGitDiff(
commitMessageExcludeRegexes: options.commitMessageExcludeRegexes,
};

return generateSummary(
return generateSummary({
diffText,
fileNames,
filteredCommits,
summarizeFlags,
options.openAiClientProvider,
commits: filteredCommits,
flags: summarizeFlags,
openAiClientProvider: options.openAiClientProvider,
diffSummary,
);
});
}

export type {
Expand All @@ -155,15 +156,18 @@ export {
getRepoRoot,
} from "./git/gitDiff.js";

export type { SummarizeFlags } from "./ai/aiSummary.js";
export type { GenerateSummaryInput, SummarizeFlags } from "./ai/aiTypes.js";
export {
DEFAULT_GIT_DIFF_SYSTEM_PROMPT,
generateSummary,
LLM_GATEWAY_REQUIRED_MESSAGE,
resolveLlmMaxDiffChars,
truncateUnifiedDiffForLlm,
} from "./ai/aiSummary.js";

export {
DEFAULT_GIT_DIFF_SYSTEM_PROMPT,
LLM_GATEWAY_REQUIRED_MESSAGE,
} from "./ai/aiConstants.js";

export type {
OpenAiLikeClient,
OpenAiLikeClientInit,
Expand Down
89 changes: 66 additions & 23 deletions test/aiSummary.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { CommitInfo } from "../src/git/gitDiff";
import * as openAIConfig from "../src/ai/openAIConfig";
import {
DEFAULT_GIT_DIFF_SYSTEM_PROMPT,
generateSummary,
LLM_GATEWAY_REQUIRED_MESSAGE,
resolveLlmMaxDiffChars,
truncateUnifiedDiffForLlm,
} from "../src/ai/aiSummary";
import {
DEFAULT_GIT_DIFF_SYSTEM_PROMPT,
LLM_GATEWAY_REQUIRED_MESSAGE,
} from "../src/ai/aiConstants";

function mockLlmClient(
content: string,
Expand Down Expand Up @@ -98,26 +100,31 @@ describe("generateSummary", () => {
jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(false);

await expect(
generateSummary("+added line", ["src/a.ts"], commits, flagsBase),
generateSummary({
diffText: "+added line",
fileNames: ["src/a.ts"],
commits,
flags: flagsBase,
}),
).rejects.toThrow(LLM_GATEWAY_REQUIRED_MESSAGE);
});

it("uses openAiClientProvider when gateway env is off", async () => {
jest.spyOn(openAIConfig, "shouldUseLlmGateway").mockReturnValue(false);

const md = await generateSummary(
"diff...",
["f.ts"],
const md = await generateSummary({
diffText: "diff...",
fileNames: ["f.ts"],
commits,
{
flags: {
...flagsBase,
team: "QA",
systemPrompt: "You are a test bot.",
model: "gpt-test",
maxDiffChars: 1000,
},
mockLlmClient(" **Summary** from inject "),
);
openAiClientProvider: mockLlmClient(" **Summary** from inject "),
});

expect(md).toBe("**Summary** from inject");
});
Expand All @@ -135,12 +142,17 @@ describe("generateSummary", () => {
},
} as Awaited<ReturnType<typeof openAIConfig.createOpenAiLikeClient>>);

const md = await generateSummary("diff...", ["f.ts"], commits, {
...flagsBase,
team: "QA",
systemPrompt: "You are a test bot.",
model: "gpt-test",
maxDiffChars: 1000,
const md = await generateSummary({
diffText: "diff...",
fileNames: ["f.ts"],
commits,
flags: {
...flagsBase,
team: "QA",
systemPrompt: "You are a test bot.",
model: "gpt-test",
maxDiffChars: 1000,
},
});

expect(md).toBe("**Summary** from model");
Expand Down Expand Up @@ -170,7 +182,12 @@ describe("generateSummary", () => {
chat: { completions: { create: completionCreate } },
} as Awaited<ReturnType<typeof openAIConfig.createOpenAiLikeClient>>);

await generateSummary("d", [], [], flagsBase);
await generateSummary({
diffText: "d",
fileNames: [],
commits: [],
flags: flagsBase,
});
expect(completionCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: "gpt-4o-mini" }),
);
Expand All @@ -185,9 +202,14 @@ describe("generateSummary", () => {
chat: { completions: { create: completionCreate } },
} as Awaited<ReturnType<typeof openAIConfig.createOpenAiLikeClient>>);

await generateSummary("d", [], [], {
...flagsBase,
commitMessageExcludeRegexes: ["^WIP"],
await generateSummary({
diffText: "d",
fileNames: [],
commits: [],
flags: {
...flagsBase,
commitMessageExcludeRegexes: ["^WIP"],
},
});
const userMsg = completionCreate.mock.calls[0]![0].messages.find(
(m: { role: string }) => m.role === "user",
Expand All @@ -205,7 +227,12 @@ describe("generateSummary", () => {
chat: { completions: { create: completionCreate } },
} as Awaited<ReturnType<typeof openAIConfig.createOpenAiLikeClient>>);

await generateSummary("d", [], [], { ...flagsBase, team: " " });
await generateSummary({
diffText: "d",
fileNames: [],
commits: [],
flags: { ...flagsBase, team: " " },
});
const userMsg = completionCreate.mock.calls[0]![0].messages.find(
(m: { role: string }) => m.role === "user",
)?.content as string;
Expand All @@ -227,7 +254,13 @@ describe("generateSummary", () => {
totalAdditions: 0,
totalDeletions: 0,
};
await generateSummary("d", [], [], flagsBase, undefined, diffSummary);
await generateSummary({
diffText: "d",
fileNames: [],
commits: [],
flags: flagsBase,
diffSummary,
});
const userMsg = completionCreate.mock.calls[0]![0].messages.find(
(m: { role: string }) => m.role === "user",
)?.content as string;
Expand All @@ -247,7 +280,12 @@ describe("generateSummary", () => {
},
} as Awaited<ReturnType<typeof openAIConfig.createOpenAiLikeClient>>);

const md = await generateSummary("d", [], [], flagsBase);
const md = await generateSummary({
diffText: "d",
fileNames: [],
commits: [],
flags: flagsBase,
});
expect(md).toBe("No summary generated by OpenAI.");
});

Expand All @@ -262,7 +300,12 @@ describe("generateSummary", () => {
chat: { completions: { create: completionCreate } },
} as Awaited<ReturnType<typeof openAIConfig.createOpenAiLikeClient>>);

await generateSummary("d", [], [], flagsBase);
await generateSummary({
diffText: "d",
fileNames: [],
commits: [],
flags: flagsBase,
});
expect(completionCreate).toHaveBeenCalledWith(
expect.objectContaining({ max_tokens: 4000 }),
);
Expand Down
Loading