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
119 changes: 119 additions & 0 deletions .github/workflows/pr-validate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: PR Validate

on:
pull_request:
types: [opened, synchronize, reopened]

jobs:
checks:
name: Typecheck, format, and build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"

- name: Install pnpm
run: npm install -g pnpm

- name: Install dependencies
run: pnpm install

- name: Typecheck
run: pnpm run typecheck

- name: Check formatting
run: pnpm exec prettier . --check

- name: Build project
env:
GOOGLE_CLIENT_ID: pr-validation-not-a-real-client-id
GOOGLE_CLIENT_SECRET: pr-validation-not-a-real-secret
run: pnpm run build

publish-preview:
name: Publish preview
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Install semver CLI
run: npm install -g semver

- name: Detect publish on merge
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
HEAD_VERSION=$(node -p "require('./package.json').version")
PACKAGE_NAME=$(node -p "require('./package.json').name")
git show "${BASE_SHA}:package.json" > /tmp/base-package.json
BASE_VERSION=$(node -p "require('/tmp/base-package.json').version")

echo "Package: $PACKAGE_NAME"
echo "Base: $BASE_VERSION"
echo "Head: $HEAD_VERSION"

if ! semver "$HEAD_VERSION" >/dev/null; then
echo "::error::Head version '$HEAD_VERSION' is not valid semver."
{
echo "### Invalid version"
echo ""
echo "\`$HEAD_VERSION\` is not a valid semver string."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi

if [ "$HEAD_VERSION" != "$BASE_VERSION" ] && ! semver "$HEAD_VERSION" -r ">$BASE_VERSION" >/dev/null; then
echo "::error::Version change $BASE_VERSION -> $HEAD_VERSION is not a valid forward bump."
{
echo "### Invalid version bump"
echo ""
echo "\`$BASE_VERSION\` -> \`$HEAD_VERSION\` is not a valid forward semver bump."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi

PUBLISHED=$(npm view "${PACKAGE_NAME}@${HEAD_VERSION}" version 2>/dev/null || true)

if [ -n "$PUBLISHED" ]; then
if [ "$HEAD_VERSION" = "$BASE_VERSION" ]; then
{
echo "### No publish on merge"
echo ""
echo "Version pinned at \`$HEAD_VERSION\` and already published on NPM — merging will not publish \`$PACKAGE_NAME\`."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi

echo "::error::Version $HEAD_VERSION is already published on NPM."
{
echo "### Invalid version bump"
echo ""
echo "Version \`$HEAD_VERSION\` is already published on NPM. Bump to a new unpublished version."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi

if [ "$HEAD_VERSION" = "$BASE_VERSION" ]; then
{
echo "### Will publish on merge"
echo ""
echo "Version pinned at \`$HEAD_VERSION\` but not yet published on NPM — merging will publish \`${PACKAGE_NAME}@${HEAD_VERSION}\`."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi

{
echo "### Will publish on merge"
echo ""
echo "Merging will publish \`${PACKAGE_NAME}@${HEAD_VERSION}\` (bumped from \`$BASE_VERSION\`)."
} >> "$GITHUB_STEP_SUMMARY"
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
dist

# Temporary files
/tmp
/tmp

# Claude folder
.claude
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,15 @@ commit setup

You will be prompted to choose:

- **Auth method**: OAuth (sign in with your Google or ChatGPT account) or API Key (paste your own key)
- **AI provider**: Google Gemini, OpenAI, or Anthropic
- **Auth method**:
- Google Gemini: Google OAuth or API key
- OpenAI: Sign in with ChatGPT or API key
- Anthropic: Claude setup-token or API key
- **Commit convention**: Conventional, Imperative, or Custom

If you want to use your claude.ai subscription with Anthropic, run `claude setup-token` in another terminal first, then paste the generated setup-token during `commit setup`.

To re-authenticate at any time:

```bash
Expand Down Expand Up @@ -151,12 +157,9 @@ commit --help

## Providers

- **Google Gemini** — API key or Google OAuth
- **OpenAI** — API key or sign in with your ChatGPT Plus/Pro subscription

More providers coming soon:

- **Anthropic** (Claude)
- **Google Gemini** — Google OAuth or API key
- **OpenAI** — Sign in with your ChatGPT Plus/Pro subscription or API key
- **Anthropic** (Claude) — Claude setup-token (`claude setup-token`) or API key

Contributions and feedback are welcome!

Expand Down
10 changes: 5 additions & 5 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Commit } from "@/app/commands/commit";
import { Setup } from "@/app/commands/setup";
import { Doctor } from "@/app/commands/doctor";
import { ModelCommand } from "@/app/commands/model";
import { parseArgs, showHelp, showVersion } from "@/app/cli";
import { Commit } from "@/cli/commit";
import { Setup } from "@/cli/setup";
import { Doctor } from "@/cli/doctor";
import { ModelCommand } from "@/cli/model";
import { parseArgs, showHelp, showVersion } from "@/cli/parser";
import { Future } from "@/libs/future";

import color from "picocolors";
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rafaeelricco/commit-tools",
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"bin": {
"commit": "./dist/index.js"
Expand Down
63 changes: 30 additions & 33 deletions src/app/commands/commit.ts → src/cli/commit.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,16 @@
export { Commit };

import * as p from "@clack/prompts";
import * as repo from "@/infra/git/repo";

import { Future } from "@/libs/future";
import { loadConfig } from "@/lib/storage/config";
import { Setup } from "@/app/commands/setup";
import { CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config";
import { resolveProvider } from "@/app/services/resolveProvider";
import {
checkIsGitRepo,
getStagedDiff,
performCommit,
performPush,
getCurrentBranch,
hasUpstream
} from "@/lib/git/repo";
import { generateCommitMessage, refineCommitMessage } from "@/app/services/llm";
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 { Nothing, type Maybe, Just } from "@/libs/maybe";
import { loading } from "@/lib/ui/spinner";
import { loading } from "@/infra/ui/spinner";

import color from "picocolors";

Expand Down Expand Up @@ -47,9 +40,14 @@ class Commit {
}

run(): Future<Error, void> {
return checkIsGitRepo()
return repo
.checkIsGitRepo()
.chain(() => this.diff())
.chain((diff) => this.generate(diff).chain((message) => this.interact(diff, message)))
.chain((diff) =>
this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) =>
this.interact(diff, message)
)
)
.mapRej((e) => {
if (e instanceof Error) {
p.log.error(color.red(e.message));
Expand All @@ -59,19 +57,14 @@ class Commit {
}

diff(): Future<Error, string> {
return getStagedDiff();
return repo.getStagedDiff();
}

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

Expand All @@ -80,7 +73,7 @@ class Commit {
}

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

push(branch?: string, publish = false, forceWithLease = false): Future<Error, void> {
Expand All @@ -94,7 +87,7 @@ class Commit {
: publish ? "Published successfully!"
: "Pushed successfully!";

return loading(startMsg, endMsg, performPush(branch, publish, forceWithLease)).map(() => {});
return loading(startMsg, endMsg, repo.performPush(branch, publish, forceWithLease)).map(() => {});
}

interact(diff: string, message: string): Future<Error, void> {
Expand All @@ -105,7 +98,9 @@ class Commit {
case "commit_push":
return this.handleCommitAndPush(message);
case "regenerate":
return this.generate(diff, "imperative").chain((msg) => this.interact(diff, msg));
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);
case "cancel":
Expand Down Expand Up @@ -157,15 +152,17 @@ class Commit {
}

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

private promptPublishBranch(): Future<Error, void> {
return getCurrentBranch().chain((branch) =>
return repo.getCurrentBranch().chain((branch) =>
Future.attemptP(async () => {
const publish = await p.confirm({
message: `Branch '${branch}' has no upstream. Publish to origin?`
Expand Down
4 changes: 2 additions & 2 deletions src/app/commands/doctor.ts → src/cli/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
export { Doctor };

import { Future } from "@/libs/future";
import { CONFIG_FILE, loadConfig } from "@/lib/storage/config";
import { CONFIG_FILE, loadConfig } from "@/infra/storage/config";
import { type AuthMethod, type ProviderConfig } from "@/domain/config/config";
import { access } from "node:fs/promises";
import { environment } from "@/app/integrations";
import { environment } from "@/infra/env";

import color from "picocolors";
import Table from "cli-table3";
Expand Down
9 changes: 5 additions & 4 deletions src/app/commands/model.ts → src/cli/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import * as p from "@clack/prompts";

import { Future } from "@/libs/future";
import { type Config, type ProviderConfig } from "@/domain/config/config";
import { loadConfig, saveConfig } from "@/lib/storage/config";
import { resolveProvider } from "@/app/services/resolveProvider";
import { fetchModels, selectModelInteractively } from "@/domain/commit/model";
import { loading } from "@/lib/ui/spinner";
import { loadConfig, saveConfig } from "@/infra/storage/config";
import { resolveProvider } from "@/domain/llm/auth-resolver";
import { fetchModels } from "@/domain/commit/models";
import { selectModelInteractively } from "@/infra/ui/model-picker";
import { loading } from "@/infra/ui/spinner";

import color from "picocolors";

Expand Down
File renamed without changes.
13 changes: 7 additions & 6 deletions src/app/commands/setup.ts → src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import * as p from "@clack/prompts";
import type { Option } from "@clack/prompts";

import { Future } from "@/libs/future";
import { saveConfig } from "@/lib/storage/config";
import { saveConfig } from "@/infra/storage/config";
import { CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config";
import { performOAuthFlow, validateOAuthTokens } from "@/lib/auth/google";
import { performOpenAIOAuthFlow, validateOpenAITokens } from "@/lib/auth/openai";
import { validateAnthropicApiKey, validateAnthropicSetupToken } from "@/lib/auth/anthropic";
import { performOAuthFlow, validateOAuthTokens } from "@/infra/auth/google";
import { performOpenAIOAuthFlow, validateOpenAITokens } from "@/infra/auth/openai";
import { validateAnthropicApiKey, validateAnthropicSetupToken } from "@/infra/auth/anthropic";
import { Just, Nothing } from "@/libs/maybe";
import { loading } from "@/lib/ui/spinner";
import { fetchModels, selectModelInteractively } from "@/domain/commit/model";
import { loading } from "@/infra/ui/spinner";
import { fetchModels } from "@/domain/commit/models";
import { selectModelInteractively } from "@/infra/ui/model-picker";

import color from "picocolors";

Expand Down
Loading