mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-09-17 07:26:26 +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,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
|
||||
}
|
||||
Reference in New Issue
Block a user