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
12 changes: 6 additions & 6 deletions .github/workflows/pr-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ jobs:
with:
node-version: "24"

- name: Install pnpm
run: npm install -g pnpm
- name: Enable Corepack
run: corepack enable

- name: Install dependencies
run: pnpm install
run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm run typecheck
Expand Down Expand Up @@ -51,11 +51,11 @@ jobs:
with:
node-version: "24"

- name: Install pnpm
run: npm install -g pnpm
- name: Enable Corepack
run: corepack enable

- name: Install dependencies
run: pnpm install
run: pnpm install --frozen-lockfile

- name: Lint (cognitive complexity)
run: pnpm run lint:ci
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"name": "@rafaeelricco/commit-tools",
"version": "0.2.7",
"version": "0.2.8",
"type": "module",
"packageManager": "pnpm@10.33.0",
"bin": {
"commit": "./dist/index.js"
},
Expand Down
6 changes: 6 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
packages:
- .

allowBuilds:
"esbuild@0.27.4": true
"protobufjs@7.5.5": true
61 changes: 32 additions & 29 deletions src/cli/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { loadConfig } from "@/infra/storage/config";
import { Setup } from "@/cli/setup";
import { type CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config";
import { resolveProvider } from "@/domain/llm/auth-resolver";
import { generateCommitMessage, refineCommitMessage } from "@/domain/llm/router";
import { generateCommitMessage, refineCommitMessage, type GeneratedContent, type LlmRequestMetadata } from "@/domain/llm/router";
import { Nothing, type Maybe, Just } from "@/libs/maybe";
import { loading } from "@/infra/ui/spinner";
import { renderPushNote } from "@/infra/ui/push-note";
import { renderCommitNote, renderPushNote } from "@/infra/ui/push-note";

import color from "picocolors";

Expand Down Expand Up @@ -56,19 +56,19 @@ class Commit {
return repo.getStagedDiff();
}

generate(diff: string, convention: CommitConvention, template: Maybe<string> = Nothing()): Future<Error, string> {
generate(diff: string, convention: CommitConvention, template: Maybe<string> = Nothing()): Future<Error, GeneratedContent> {
return loading("Generating commit message...", "Message generated!", generateCommitMessage(this.providerConfig, diff, convention, template));
}

refine(message: string, adjustment: string, diff: string): Future<Error, string> {
refine(message: string, adjustment: string, diff: string): Future<Error, GeneratedContent> {
return loading("Refining...", "Refined!", refineCommitMessage(this.providerConfig, message, adjustment, diff));
}

commit(message: string): Future<Error, string> {
return repo.performCommit(message);
}

push(branch?: string, publish = false, forceWithLease = false): Future<Error, void> {
push(request: Maybe<LlmRequestMetadata>, branch?: string, publish = false, forceWithLease = false): Future<Error, void> {
const startMsg =
forceWithLease ? "Force pushing with lease..."
: publish ? `Publishing '${branch}'...`
Expand All @@ -95,21 +95,21 @@ class Commit {
baseBranch: repo.findBaseBranch(),
remoteUrl: repo.findTrackingRemoteUrl(),
pr: pr.getOpenPullRequest()
}).map((parts) => renderPushNote({ ...parts, range: result.range }))
}).map((parts) => renderPushNote({ ...parts, range: result.range, request }))
);
}

interact(diff: string, message: string): Future<Error, void> {
return this.promptAction(message).chain((action) => {
interact(diff: string, generated: GeneratedContent): Future<Error, void> {
return this.promptAction(generated.text).chain((action) => {
switch (action) {
case "commit":
return this.handleCommit(message);
return this.handleCommit(generated);
case "commit_push":
return this.handleCommitAndPush(message);
return this.handleCommitAndPush(generated);
case "regenerate":
return this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((msg) => this.interact(diff, msg));
case "adjust":
return this.handleAdjust(diff, message);
return this.handleAdjust(diff, generated);
case "cancel":
return Future.resolve(undefined);
}
Expand Down Expand Up @@ -140,59 +140,62 @@ class Commit {
});
}

private handleCommit(message: string): Future<Error, void> {
return this.commit(message).map((stats) => {
process.stdout.write(stats);
p.outro(color.green("Committed successfully!"));
});
private handleCommit(generated: GeneratedContent): Future<Error, void> {
return this.commit(generated.text).chain((stats) =>
repo.findCommitMetadata().map((commit) => {
process.stdout.write(stats);
renderCommitNote({ commit, request: Just(generated.metadata) });
p.outro(color.green("Committed successfully!"));
})
);
}

private handleCommitAndPush(message: string): Future<Error, void> {
return this.commit(message)
private handleCommitAndPush(generated: GeneratedContent): Future<Error, void> {
return this.commit(generated.text)
.chain((stats) => {
process.stdout.write(stats);
return this.pushAfterCommit();
return this.pushAfterCommit(Just(generated.metadata));
})
.map(() => {
p.outro(color.green("Done!"));
});
}

private pushAfterCommit(): Future<Error, void> {
private pushAfterCommit(request: Maybe<LlmRequestMetadata>): Future<Error, void> {
return repo
.hasUpstream()
.chain((exists) =>
exists ?
this.push().chainRej((err) => (isNonFastForwardError(err) ? this.promptForceWithLease() : Future.reject(err)))
: this.promptPublishBranch()
this.push(request).chainRej((err) => (isNonFastForwardError(err) ? this.promptForceWithLease(request) : Future.reject(err)))
: this.promptPublishBranch(request)
);
}

private promptPublishBranch(): Future<Error, void> {
private promptPublishBranch(request: Maybe<LlmRequestMetadata>): Future<Error, void> {
return repo.getCurrentBranch().chain((branch) =>
Future.attemptP(async () => {
const publish = await p.confirm({
message: `Branch '${branch}' has no upstream. Publish to origin?`
});
return !(p.isCancel(publish) || !publish);
}).chain((shouldPublish) => (shouldPublish ? this.push(branch, true) : Future.resolve(undefined)))
}).chain((shouldPublish) => (shouldPublish ? this.push(request, branch, true) : Future.resolve(undefined)))
);
}

private promptForceWithLease(): Future<Error, void> {
private promptForceWithLease(request: Maybe<LlmRequestMetadata>): Future<Error, void> {
return Future.attemptP(async () => {
const force = await p.confirm({
message: "Push was rejected (branch is behind remote). Force push with lease?"
});
return !(p.isCancel(force) || !force);
}).chain((shouldForce) => (shouldForce ? this.push(undefined, false, true) : Future.resolve(undefined)));
}).chain((shouldForce) => (shouldForce ? this.push(request, undefined, false, true) : Future.resolve(undefined)));
}

private handleAdjust(diff: string, message: string): Future<Error, void> {
private handleAdjust(diff: string, generated: GeneratedContent): Future<Error, void> {
return this.promptAdjustment().chain((maybeAdj) =>
maybeAdj instanceof Nothing ?
this.interact(diff, message)
: this.refine(message, maybeAdj.value, diff).chain((refined) => this.interact(diff, refined))
this.interact(diff, generated)
: this.refine(generated.text, maybeAdj.value, diff).chain((refined) => this.interact(diff, refined))
);
}

Expand Down
73 changes: 66 additions & 7 deletions src/domain/llm/router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
export { type GenerateContentParams, generateCommitMessage, refineCommitMessage };
export {
type GenerateContentParams,
type GeneratedContent,
type LlmRequestMetadata,
type ModelRequestMetadata,
type ProviderGeneratedContent,
type TokenUsage,
generateCommitMessage,
refineCommitMessage
};

import { Future } from "@/libs/future";
import { type ProviderConfig, type CommitConvention } from "@/domain/config/config";
Expand All @@ -13,14 +22,64 @@ type GenerateContentParams = {
readonly systemInstruction?: string;
};

const generateContent = (config: ProviderConfig, params: GenerateContentParams): Future<Error, string> => {
type TokenUsage = {
readonly input: Maybe<number>;
readonly output: Maybe<number>;
readonly total: Maybe<number>;
};

type ModelRequestMetadata = {
readonly provider: ProviderConfig["provider"];
readonly model: string;
readonly effort: string;
};

type LlmRequestMetadata = {
readonly durationMs: number;
readonly model: ModelRequestMetadata;
readonly tokens: Maybe<TokenUsage>;
};

type GeneratedContent = {
readonly text: string;
readonly metadata: LlmRequestMetadata;
};

type ProviderGeneratedContent = {
readonly text: string;
readonly tokens: Maybe<TokenUsage>;
};

const modelRequestMetadata = (config: ProviderConfig): ModelRequestMetadata => {
switch (config.provider) {
case "openai":
return { provider: config.provider, model: config.model, effort: config.effort.maybe<string>("provider default", (effort) => effort) };
case "gemini":
case "anthropic":
return { provider: config.provider, model: config.model, effort: config.effort.maybe<string>("medium", (effort) => effort) };
}
};

const withRequestMetadata = (config: ProviderConfig, f: Future<Error, ProviderGeneratedContent>): Future<Error, GeneratedContent> => {
const startedAt = Date.now();
return f.map(({ text, tokens }) => ({
text,
metadata: {
durationMs: Date.now() - startedAt,
model: modelRequestMetadata(config),
tokens
}
}));
};

const generateContent = (config: ProviderConfig, params: GenerateContentParams): Future<Error, GeneratedContent> => {
switch (config.provider) {
case "gemini":
return generateContentWithGemini(config, params);
return withRequestMetadata(config, generateContentWithGemini(config, params));
case "openai":
return generateContentWithOpenAI(config, params);
return withRequestMetadata(config, generateContentWithOpenAI(config, params));
case "anthropic":
return generateContentWithAnthropic(config, params);
return withRequestMetadata(config, generateContentWithAnthropic(config, params));
}
};

Expand All @@ -29,7 +88,7 @@ const generateCommitMessage = (
diff: string,
convention: CommitConvention,
customTemplate: Maybe<string> = Nothing()
): Future<Error, string> => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) });
): Future<Error, GeneratedContent> => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) });

const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future<Error, string> =>
const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future<Error, GeneratedContent> =>
generateContent(config, getRefinePrompt({ diff, currentMessage, adjustment }));
50 changes: 40 additions & 10 deletions src/infra/llm/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export { generateContentWithAnthropic };
import Anthropic from "@anthropic-ai/sdk";

import { type Config, type AnthropicEffort } from "@/domain/config/config";
import { type GenerateContentParams } from "@/domain/llm/router";
import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router";
import { Future } from "@/libs/future";
import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/infra/auth/anthropic";
import { absurd } from "@/libs/types";
Expand All @@ -20,6 +20,15 @@ const extractAnthropicText = (content: Anthropic.ContentBlock[]): string =>
.map((b) => b.text)
.join("");

const toTokenUsage = (usage: Anthropic.Usage): TokenUsage => {
const input = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
return {
input: Just(input),
output: Just(usage.output_tokens),
total: Just(input + usage.output_tokens)
};
};

const buildParams = (
model: string,
system: Maybe<SystemParam>,
Expand All @@ -42,31 +51,52 @@ const buildSetupTokenSystem = (instruction: Maybe<string>): SystemParam =>
{ type: "text", text }
]);

const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe<AnthropicEffort>, params: GenerateContentParams): Future<Error, string> =>
const callAnthropicWithApiKey = (
apiKey: string,
model: string,
effort: Maybe<AnthropicEffort>,
params: GenerateContentParams
): Future<Error, ProviderGeneratedContent> =>
Future.attemptP(async () => {
const client = new Anthropic({ apiKey });
const client = new Anthropic({ apiKey, maxRetries: 3, timeout: 120_000 });
const stream = client.messages.stream(buildParams(model, fromOptional(params.systemInstruction), effort, params));
return await stream.finalMessage();
})
.mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`))
.chain((message) => extractResponse({ text: Just(extractAnthropicText(message.content)) }));
.mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error }))
.chain((message) =>
extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({
text,
tokens: Just(toTokenUsage(message.usage))
}))
);

const callAnthropicWithSetupToken = (
authToken: string,
model: string,
effort: Maybe<AnthropicEffort>,
params: GenerateContentParams
): Future<Error, string> =>
): Future<Error, ProviderGeneratedContent> =>
Future.attemptP(async () => {
const client = new Anthropic({ apiKey: null, authToken, defaultHeaders: anthropicOAuthHeaders() });
const client = new Anthropic({
apiKey: null,
authToken,
defaultHeaders: anthropicOAuthHeaders(),
maxRetries: 3,
timeout: 120_000
});
const system = Just<SystemParam>(buildSetupTokenSystem(fromOptional(params.systemInstruction)));
const stream = client.messages.stream(buildParams(model, system, effort, params));
return await stream.finalMessage();
})
.mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`))
.chain((message) => extractResponse({ text: Just(extractAnthropicText(message.content)) }));
.mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error }))
.chain((message) =>
extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({
text,
tokens: Just(toTokenUsage(message.usage))
}))
);

const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateContentParams): Future<Error, string> => {
const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateContentParams): Future<Error, ProviderGeneratedContent> => {
switch (config.auth_method.type) {
case "api_key":
return callAnthropicWithApiKey(config.auth_method.content, config.model, config.effort, params);
Expand Down
Loading