mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-09-16 23:16:25 +00:00
feat: rewrite gh-commit in Go with local Ollama backend
Replaces the Python/Crush/DuckDB implementation with a CGO-free Go binary: cobra CLI, modernc.org/sqlite storage, huh/lipgloss UI, and speed-tuned raw /api/generate calls (think:false, keep_alive, capped num_predict) against a local qwen3.5:2b. Ships as a gh extension via cli/gh-extension-precompile.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// Package ai talks to a local Ollama server. The rozoomcool SDK is used for
|
||||
// client construction and model pulls; generation goes through a raw
|
||||
// /api/generate POST because the SDK's Generate cannot pass think, options,
|
||||
// or keep_alive — the levers this tool depends on for speed.
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ollama "github.com/rozoomcool/go-ollama-sdk"
|
||||
|
||||
"github.com/prdlk/gh-commit/internal/diff"
|
||||
"github.com/prdlk/gh-commit/internal/ui"
|
||||
)
|
||||
|
||||
// Generation profiles: small budget for one-line commit messages, larger
|
||||
// budget and context for scope JSON over big file trees.
|
||||
const (
|
||||
commitNumPredict = 96
|
||||
commitNumCtx = 8192
|
||||
scopeNumPredict = 1024
|
||||
scopeNumCtx = 16384
|
||||
keepAlive = "10m"
|
||||
temperature = 0.2
|
||||
topP = 0.9
|
||||
)
|
||||
|
||||
// Client is a speed-tuned Ollama client for one model.
|
||||
type Client struct {
|
||||
host string
|
||||
model string
|
||||
timeout time.Duration
|
||||
http *http.Client
|
||||
sdk *ollama.OllamaClient
|
||||
}
|
||||
|
||||
// New builds a client for host/model with a per-request timeout.
|
||||
func New(host, model string, timeout time.Duration) *Client {
|
||||
host = strings.TrimRight(host, "/")
|
||||
return &Client{
|
||||
host: host,
|
||||
model: model,
|
||||
timeout: timeout,
|
||||
http: &http.Client{Timeout: timeout},
|
||||
sdk: ollama.NewClient(host),
|
||||
}
|
||||
}
|
||||
|
||||
// Model returns the configured model tag.
|
||||
func (c *Client) Model() string { return c.model }
|
||||
|
||||
type generateOptions struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
TopP float64 `json:"top_p"`
|
||||
NumPredict int `json:"num_predict"`
|
||||
NumCtx int `json:"num_ctx"`
|
||||
}
|
||||
|
||||
type generateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Stream bool `json:"stream"`
|
||||
Think bool `json:"think"`
|
||||
KeepAlive string `json:"keep_alive"`
|
||||
Options generateOptions `json:"options"`
|
||||
}
|
||||
|
||||
type generateResponse struct {
|
||||
Response string `json:"response"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// generate POSTs a raw /api/generate request with the speed profile applied.
|
||||
func (c *Client) generate(prompt string, numPredict, numCtx int) (string, error) {
|
||||
body, err := json.Marshal(generateRequest{
|
||||
Model: c.model,
|
||||
Prompt: prompt,
|
||||
Stream: false,
|
||||
Think: false,
|
||||
KeepAlive: keepAlive,
|
||||
Options: generateOptions{
|
||||
Temperature: temperature,
|
||||
TopP: topP,
|
||||
NumPredict: numPredict,
|
||||
NumCtx: numCtx,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.host+"/api/generate", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ollama request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out generateResponse
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return "", fmt.Errorf("ollama returned unexpected payload: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if out.Error != "" {
|
||||
return "", fmt.Errorf("ollama: %s", out.Error)
|
||||
}
|
||||
return "", fmt.Errorf("ollama returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// think:false is sent, but strip any reasoning block as defense in depth.
|
||||
return stripThink(out.Response), nil
|
||||
}
|
||||
|
||||
// CommitMessage filters diff, prompts the model, and returns a cleaned
|
||||
// commit message ("" when the model produced nothing usable).
|
||||
func (c *Client) CommitMessage(rawDiff, scope string) (string, error) {
|
||||
prompt := buildCommitPrompt(diff.Filter(rawDiff), scope)
|
||||
out, err := c.generate(prompt, commitNumPredict, commitNumCtx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return CleanCommitMessage(out), nil
|
||||
}
|
||||
|
||||
// ScopesRaw prompts the model for a scope mapping and returns the raw
|
||||
// response text (callers parse it so they can show diagnostics on failure).
|
||||
func (c *Client) ScopesRaw(filetree string, existing map[string][]string) (string, error) {
|
||||
return c.generate(buildScopePrompt(filetree, existing), scopeNumPredict, scopeNumCtx)
|
||||
}
|
||||
|
||||
type tagsResponse struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
// EnsureReady verifies the Ollama server is answering and the model is
|
||||
// available locally, offering to pull it when missing.
|
||||
func (c *Client) EnsureReady() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.host+"/api/tags", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the Ollama server is not reachable at %s — start it with 'ollama serve'", c.host)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var tags tagsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
|
||||
return fmt.Errorf("the Ollama server at %s gave an unexpected response — start it with 'ollama serve'", c.host)
|
||||
}
|
||||
for _, m := range tags.Models {
|
||||
if m.Name == c.model || (!strings.Contains(c.model, ":") && m.Name == c.model+":latest") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ui.Warnf("Model %s is not available locally", c.model)
|
||||
if !ui.Confirm(fmt.Sprintf("Pull %s now?", c.model)) {
|
||||
return fmt.Errorf("model %s is not available — run 'ollama pull %s'", c.model, c.model)
|
||||
}
|
||||
last := ""
|
||||
if err := c.sdk.PullModel(c.model, func(status string) {
|
||||
if status != "" && status != last {
|
||||
ui.Dimf(" %s", status)
|
||||
last = status
|
||||
}
|
||||
}); err != nil {
|
||||
return fmt.Errorf("pulling %s: %w", c.model, err)
|
||||
}
|
||||
ui.Successf("✓ Pulled %s", c.model)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
thinkRe = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
fenceOpenRe = regexp.MustCompile("^```[a-zA-Z]*\n")
|
||||
fenceCloseRe = regexp.MustCompile("\n```$")
|
||||
scopedMsgRe = regexp.MustCompile(`^\w+\([^)]+\): .+`)
|
||||
plainMsgRe = regexp.MustCompile(`^\w+: .+`)
|
||||
)
|
||||
|
||||
// stripThink removes <think>...</think> reasoning blocks.
|
||||
func stripThink(text string) string {
|
||||
return strings.TrimSpace(thinkRe.ReplaceAllString(text, ""))
|
||||
}
|
||||
|
||||
// ExtractJSONObject returns the first balanced top-level {...} object in text,
|
||||
// or "" when none is found.
|
||||
func ExtractJSONObject(text string) string {
|
||||
depth := 0
|
||||
start := -1
|
||||
for i := range len(text) {
|
||||
switch text[i] {
|
||||
case '{':
|
||||
if start == -1 {
|
||||
start = i
|
||||
}
|
||||
depth++
|
||||
case '}':
|
||||
if start != -1 {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return text[start : i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CoerceScopeMap normalizes a decoded JSON/TOML mapping into scope -> paths.
|
||||
// String values become single-element slices; empty paths are dropped.
|
||||
// Returns nil when nothing usable remains.
|
||||
func CoerceScopeMap(data map[string]any) map[string][]string {
|
||||
scopes := map[string][]string{}
|
||||
for name, paths := range data {
|
||||
switch v := paths.(type) {
|
||||
case string:
|
||||
scopes[name] = []string{v}
|
||||
case []any:
|
||||
var out []string
|
||||
for _, p := range v {
|
||||
s := fmt.Sprint(p)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
scopes[name] = out
|
||||
}
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// ParseScopesResponse extracts and normalizes the scope mapping from a model
|
||||
// response. Returns nil when no usable JSON object is present.
|
||||
func ParseScopesResponse(text string) map[string][]string {
|
||||
blob := ExtractJSONObject(text)
|
||||
if blob == "" {
|
||||
return nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(blob), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
return CoerceScopeMap(data)
|
||||
}
|
||||
|
||||
// CleanCommitMessage normalizes a raw model response into a commit message:
|
||||
// trims, strips <think> blocks and code fences, prefers the first non-empty
|
||||
// line when it matches conventional-commit shape, and otherwise falls back to
|
||||
// the whole trimmed string (never blocks a commit on a regex).
|
||||
func CleanCommitMessage(raw string) string {
|
||||
msg := stripThink(strings.TrimSpace(raw))
|
||||
msg = fenceOpenRe.ReplaceAllString(msg, "")
|
||||
msg = fenceCloseRe.ReplaceAllString(msg, "")
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(msg, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if scopedMsgRe.MatchString(line) || plainMsgRe.MatchString(line) {
|
||||
return line
|
||||
}
|
||||
break
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractJSONObject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{"bare object", `{"a": 1}`, `{"a": 1}`},
|
||||
{"leading prose", `Here you go: {"a": {"b": 2}} done`, `{"a": {"b": 2}}`},
|
||||
{"nested braces balanced", `{"a": {"b": {"c": 3}}}`, `{"a": {"b": {"c": 3}}}`},
|
||||
{"first object wins", `{"a": 1} {"b": 2}`, `{"a": 1}`},
|
||||
{"no object", "no json here", ""},
|
||||
{"unbalanced", `{"a": 1`, ""},
|
||||
{"stray close before open", `} {"a": 1}`, `{"a": 1}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ExtractJSONObject(tt.in); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScopesResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want map[string][]string
|
||||
}{
|
||||
{
|
||||
"array values",
|
||||
`{"core": ["src/", "lib/"], "docs": ["README.md"]}`,
|
||||
map[string][]string{"core": {"src/", "lib/"}, "docs": {"README.md"}},
|
||||
},
|
||||
{
|
||||
"string value coerced to slice",
|
||||
`{"cli": "cmd/"}`,
|
||||
map[string][]string{"cli": {"cmd/"}},
|
||||
},
|
||||
{
|
||||
"empty paths dropped",
|
||||
`{"core": ["src/", "", " "]}`,
|
||||
map[string][]string{"core": {"src/"}},
|
||||
},
|
||||
{
|
||||
"surrounding prose ignored",
|
||||
"Sure! Here are the scopes:\n{\"api\": [\"api/\"]}\nHope that helps.",
|
||||
map[string][]string{"api": {"api/"}},
|
||||
},
|
||||
{"not an object", `["a", "b"]`, nil},
|
||||
{"invalid json", `{"a": }`, nil},
|
||||
{"empty object", `{}`, nil},
|
||||
{"no json at all", "nothing", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ParseScopesResponse(tt.in); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("got %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanCommitMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{"clean scoped message", "feat(core): add thing", "feat(core): add thing"},
|
||||
{"clean unscoped message", "chore: tidy", "chore: tidy"},
|
||||
{"whitespace trimmed", " fix(db): close conn \n", "fix(db): close conn"},
|
||||
{
|
||||
"code fence stripped",
|
||||
"```\nfeat(ui): add button\n```",
|
||||
"feat(ui): add button",
|
||||
},
|
||||
{
|
||||
"language fence stripped",
|
||||
"```text\nfix(api): handle 404\n```",
|
||||
"fix(api): handle 404",
|
||||
},
|
||||
{
|
||||
"think block stripped",
|
||||
"<think>\nLet me reason about this diff...\n</think>\nfeat(ai): wire ollama",
|
||||
"feat(ai): wire ollama",
|
||||
},
|
||||
{
|
||||
"rambling reduced to first valid line",
|
||||
"feat(core): add parser\n\nThis commit introduces a parser.",
|
||||
"feat(core): add parser",
|
||||
},
|
||||
{
|
||||
"invalid message falls back to whole string",
|
||||
"I could not produce a message",
|
||||
"I could not produce a message",
|
||||
},
|
||||
{
|
||||
"multiline invalid first line falls back to whole string",
|
||||
"Here is the commit:\nnot conventional",
|
||||
"Here is the commit:\nnot conventional",
|
||||
},
|
||||
{"empty input", "", ""},
|
||||
{"only think block", "<think>hmm</think>", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := CleanCommitMessage(tt.in); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommitPrompt(t *testing.T) {
|
||||
p := buildCommitPrompt("DIFF_BODY", "core")
|
||||
for _, want := range []string{"Use this scope: core", "DIFF_BODY", "Do not think. Answer immediately."} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
if p2 := buildCommitPrompt("DIFF_BODY", ""); strings.Contains(p2, "Use this scope") {
|
||||
t.Error("scope hint should be absent without a scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScopePrompt(t *testing.T) {
|
||||
p := buildScopePrompt("a.go\nb.go", nil)
|
||||
for _, want := range []string{"a.go\nb.go", `{"scope": ["path", ...], ...}`, "Do not think. Answer immediately."} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("new prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(p, "Existing scopes") {
|
||||
t.Error("new prompt must not mention existing scopes")
|
||||
}
|
||||
|
||||
p = buildScopePrompt("a.go", map[string][]string{"core": {"src/"}})
|
||||
for _, want := range []string{"Existing scopes (JSON):", `"core"`, `"src/"`} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("update prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Prompt text is ported unchanged from the Python tool, plus one
|
||||
// "Do not think" line per prompt as cheap insurance on small models.
|
||||
|
||||
const commitPrompt = `You are an expert conventional commit message writer.
|
||||
Use one of these commit types: feat, fix, docs, style, refactor, breaking, test, perf, build, ci, chore, init.
|
||||
Determine a precise commit message from the provided diff and scope.
|
||||
The commit message must follow this format: <type>(<scope>): <description>
|
||||
Examples:
|
||||
- fix(core): resolve consensus timeout during high load
|
||||
- feat(did): add WebAuthn biometric authentication support
|
||||
- refactor(hway): extract Redis connection pooling to shared utility
|
||||
- init(browser): setup client package for web API-specific logic
|
||||
- perf(vault): optimize IPFS chunk size for large file uploads
|
||||
- ci(actions): update GitHub Actions pipeline for parallel module testing
|
||||
- docs(dwn): clarify data retention policies in API reference
|
||||
- test(svc): add integration tests for domain verification flow
|
||||
- breaking(ui): rename Button prop 'type' to 'variant'
|
||||
- refactor(sdk): migrate off wallet implementation in favor of @sonr.io/enclave
|
||||
- feat(react): create Enclave stateful hooks
|
||||
Never explain anything. Return only the commit message.
|
||||
Do not think. Answer immediately.
|
||||
|
||||
{scope_hint}### Git Diff
|
||||
{diff}
|
||||
`
|
||||
|
||||
const scopeInstructions = `You are a repository scope identifier for Conventional Commits.
|
||||
You receive a repository file tree, one repo-relative path per line.
|
||||
Map logical project areas to the repo-relative path prefixes (directories or files) they cover.
|
||||
Scope names must read well in Conventional Commits, e.g. core, api, ui, cli, docs, tests, ci, config, scripts, deps.
|
||||
Each scope maps to a JSON array of repo-relative path prefixes.
|
||||
Group related paths together and cover the meaningful source areas.
|
||||
Never create scopes for generated, vendored, or build-output paths.
|
||||
If existing scopes are provided, keep the ones that still apply, drop scopes whose paths no longer exist, and add scopes for new areas.
|
||||
Never explain anything or print your thoughts. Never include Markdown code blocks.
|
||||
Only return a single JSON object of the form {"scope": ["path", ...], ...}.
|
||||
Do not think. Answer immediately.
|
||||
`
|
||||
|
||||
const scopePromptNew = scopeInstructions + `
|
||||
Repository file tree (one repo-relative path per line):
|
||||
|
||||
{filetree}
|
||||
`
|
||||
|
||||
const scopePromptUpdate = scopeInstructions + `
|
||||
Existing scopes (JSON):
|
||||
{existing}
|
||||
|
||||
The repository structure may have changed (e.g. its .gitignore was edited).
|
||||
Updated repository file tree (one repo-relative path per line):
|
||||
|
||||
{filetree}
|
||||
`
|
||||
|
||||
// buildCommitPrompt fills the commit prompt with an optional scope hint and
|
||||
// the (already filtered) diff.
|
||||
func buildCommitPrompt(filteredDiff, scope string) string {
|
||||
hint := ""
|
||||
if scope != "" {
|
||||
hint = "Use this scope: " + scope + "\n"
|
||||
}
|
||||
return strings.NewReplacer("{scope_hint}", hint, "{diff}", filteredDiff).Replace(commitPrompt)
|
||||
}
|
||||
|
||||
// buildScopePrompt fills the scope prompt; existing may be nil for first-time
|
||||
// generation.
|
||||
func buildScopePrompt(filetree string, existing map[string][]string) string {
|
||||
if len(existing) == 0 {
|
||||
return strings.Replace(scopePromptNew, "{filetree}", filetree, 1)
|
||||
}
|
||||
blob, _ := json.MarshalIndent(existing, "", " ")
|
||||
return strings.NewReplacer(
|
||||
"{existing}", string(blob),
|
||||
"{filetree}", filetree,
|
||||
).Replace(scopePromptUpdate)
|
||||
}
|
||||
Reference in New Issue
Block a user