An AI-powered tool that generates human-readable summaries of git changes using tool calling with a self-hosted LLM

initial commit

Changed files
+1950
.tangled
workflows
cmd
git-summarizer
internal
pkg
cache
config
git
llm
+46
.tangled/workflows/release.yml
··· 1 + when: 2 + - event: ["push"] 3 + branch: ["main"] 4 + tag: ["v*"] 5 + 6 + engine: kubernetes 7 + image: quay.io/buildah/stable:latest 8 + architecture: amd64 9 + 10 + environment: 11 + IMAGE_REGISTRY: atcr.io 12 + IMAGE_NAME: ${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/${TANGLED_REPO_NAME} 13 + 14 + steps: 15 + - name: Login to registry 16 + command: | 17 + echo "${APP_PASSWORD}" | buildah login \ 18 + -u "${IMAGE_USER}" \ 19 + --password-stdin \ 20 + ${IMAGE_REGISTRY} 21 + 22 + - name: Build amd64 image 23 + command: | 24 + buildah bud \ 25 + --arch amd64 \ 26 + --build-arg TARGETARCH=amd64 \ 27 + --tag ${IMAGE_NAME}:${TANGLED_REF_NAME}-amd64 \ 28 + --file ./Dockerfile \ 29 + . 30 + 31 + - name: Build arm64 image 32 + command: | 33 + buildah bud \ 34 + --arch arm64 \ 35 + --build-arg TARGETARCH=arm64 \ 36 + --tag ${IMAGE_NAME}:${TANGLED_REF_NAME}-arm64 \ 37 + --file ./Dockerfile \ 38 + . 39 + 40 + - name: Create and push manifest 41 + command: | 42 + buildah manifest create ${IMAGE_NAME}:${TANGLED_REF_NAME} 43 + buildah manifest add ${IMAGE_NAME}:${TANGLED_REF_NAME} ${IMAGE_NAME}:${TANGLED_REF_NAME}-amd64 44 + buildah manifest add ${IMAGE_NAME}:${TANGLED_REF_NAME} ${IMAGE_NAME}:${TANGLED_REF_NAME}-arm64 45 + buildah manifest push --all ${IMAGE_NAME}:${TANGLED_REF_NAME} docker://${IMAGE_NAME}:${TANGLED_REF_NAME} 46 + buildah manifest push --all ${IMAGE_NAME}:${TANGLED_REF_NAME} docker://${IMAGE_NAME}:latest
+72
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Build and Run Commands 6 + 7 + ```bash 8 + # Build the binary 9 + make build 10 + 11 + # Run locally (requires llama.cpp server running) 12 + make run 13 + 14 + # Build Docker image 15 + make docker 16 + 17 + # Run Docker container 18 + make docker-run 19 + 20 + # Test with a public repo (requires running server) 21 + make test 22 + ``` 23 + 24 + ## Architecture 25 + 26 + This is an AI-powered git change summarizer that uses an agentic tool-calling loop with a self-hosted LLM. 27 + 28 + **Project structure:** 29 + 30 + ``` 31 + ├── cmd/git-summarizer/main.go # Entry point 32 + ├── pkg/ 33 + │ ├── config/config.go # Config struct + flag/env loading 34 + │ ├── git/repo.go # Git operations wrapper (go-git) 35 + │ └── llm/ 36 + │ ├── types.go # OpenAI-compatible API types 37 + │ └── tools.go # LLM tool definitions 38 + ├── internal/ 39 + │ ├── api/handlers.go # HTTP handlers (/summarize, /health) 40 + │ └── summarizer/ 41 + │ ├── summarizer.go # Agentic loop orchestration 42 + │ └── prompts.go # System prompts by style 43 + ``` 44 + 45 + **Key packages:** 46 + 47 + - **pkg/config**: Application configuration for LLM connection, authentication, and server settings 48 + - **pkg/git**: Wrapper around go-git providing git operations (log, diff, file reading) 49 + - **pkg/llm**: OpenAI-compatible types and tool definitions 50 + - **internal/summarizer**: Orchestrator that runs the agentic loop - sends prompts to LLM, executes tool calls, returns results until summary is produced 51 + - **internal/api**: HTTP request handlers 52 + 53 + **Tool-calling flow:** 54 + 1. Receives POST /summarize request with repo URL/path and base/head refs 55 + 2. Clones repo (if URL) or opens existing path 56 + 3. Sends system prompt + user request to LLM with available tools 57 + 4. LLM calls tools (git_log, git_diff, list_changed_files, etc.) to explore the repo 58 + 5. Summarizer executes tools locally via go-git and returns results 59 + 6. Loop continues (max 10 iterations) until LLM produces final summary 60 + 61 + **Available LLM tools:** git_log, git_diff, list_changed_files, git_show_commit, read_file, git_diff_stats 62 + 63 + **Dependencies:** 64 + - Uses go-git for pure Go git operations (no git binary required) 65 + - Requires external llama.cpp/Ollama server for LLM inference 66 + - Recommended models: Qwen2.5-Coder, Llama 3.1+, Mistral/Mixtral 67 + 68 + ## Configuration 69 + 70 + Flags: `--llama-url`, `--model`, `--listen`, `--repo-dir`, `--max-diff`, `--git-user`, `--git-token`, `--ssh-key` 71 + 72 + Environment variables: `LLAMA_URL`, `LLAMA_MODEL`, `GIT_USER`, `GIT_TOKEN`, `SSH_KEY_PATH`
+24
Dockerfile
··· 1 + FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder 2 + 3 + ARG TARGETARCH=amd64 4 + 5 + WORKDIR /app 6 + COPY go.mod go.sum ./ 7 + RUN go mod download 8 + 9 + COPY . ./ 10 + RUN CGO_ENABLED=0 GOARCH=$TARGETARCH go build -ldflags="-s -w" -o git-summarizer ./cmd/git-summarizer 11 + 12 + FROM alpine:3.20 13 + 14 + # Only need ca-certificates for HTTPS cloning 15 + RUN apk add --no-cache ca-certificates 16 + 17 + COPY --from=builder /app/git-summarizer /usr/local/bin/ 18 + 19 + # For SSH cloning (optional - mount your keys) 20 + RUN mkdir -p /root/.ssh && chmod 700 /root/.ssh 21 + 22 + EXPOSE 8000 23 + 24 + ENTRYPOINT ["git-summarizer"]
+36
Makefile
··· 1 + .PHONY: build run docker test clean 2 + 3 + # Build binary 4 + build: 5 + go mod tidy 6 + go build -ldflags="-s -w" -o git-summarizer ./cmd/git-summarizer 7 + 8 + # Run locally 9 + run: build 10 + ./git-summarizer 11 + 12 + # Build docker image 13 + docker: 14 + docker build -t git-summarizer:latest . 15 + 16 + # Run docker locally 17 + docker-run: 18 + docker run --network=host \ 19 + -e LLAMA_URL=https://llama.j5t.io \ 20 + -e LLAMA_MODEL=qwen3-coder-30b \ 21 + git-summarizer:latest 22 + 23 + # Test with a public repo 24 + test: 25 + curl -X POST http://localhost:8000/summarize \ 26 + -H "Content-Type: application/json" \ 27 + -d '{"repo_url": "https://tangled.org/evan.jarrett.net/at-container-registry", "base": "v0.0.10", "head": "main"}' 28 + 29 + # Test bluesky-style short summary 30 + test-bluesky: 31 + curl -X POST http://localhost:8000/summarize \ 32 + -H "Content-Type: application/json" \ 33 + -d '{"repo_url": "https://tangled.org/evan.jarrett.net/at-container-registry", "base": "v0.0.10", "head": "main", "style": "bluesky"}' 34 + 35 + clean: 36 + rm -f git-summarizer
+169
README.md
··· 1 + # Git Summarizer 2 + 3 + An AI-powered tool that generates human-readable summaries of git changes using tool calling with a self-hosted LLM (llama.cpp, Ollama, etc). 4 + 5 + Uses [go-git](https://github.com/go-git/go-git) for pure Go git operations — no git binary required. 6 + 7 + ## Architecture 8 + 9 + ``` 10 + ┌─────────────────────┐ ┌─────────────────────┐ 11 + │ git-summarizer │ │ llama.cpp server │ 12 + │ (this service) │ ──────▶ │ (your GPU) │ 13 + │ │ API │ │ 14 + │ - clones repos │ ◀────── │ - qwen2.5-coder │ 15 + │ - runs git ops │ │ │ 16 + │ - tool call loop │ │ │ 17 + └─────────────────────┘ └─────────────────────┘ 18 + ``` 19 + 20 + The summarizer acts as an orchestrator — it doesn't need GPU access. It: 21 + 1. Receives a request to summarize changes 22 + 2. Sends prompts to your LLM server 23 + 3. Executes git tool calls locally (using go-git) 24 + 4. Returns results to the LLM 25 + 5. Repeats until the LLM produces a summary 26 + 27 + ### Project Structure 28 + 29 + ``` 30 + ├── cmd/git-summarizer/main.go # Entry point 31 + ├── pkg/ 32 + │ ├── config/ # Configuration loading 33 + │ ├── git/ # Git operations (go-git wrapper) 34 + │ └── llm/ # LLM API types and tool definitions 35 + ├── internal/ 36 + │ ├── api/ # HTTP handlers 37 + │ └── summarizer/ # Agentic loop orchestration 38 + ``` 39 + 40 + ## Recommended Models 41 + 42 + For tool calling support, these work well: 43 + - **Qwen2.5-Coder** (7B, 14B, 32B) — Best for this task 44 + - **Llama 3.1+** (8B, 70B) 45 + - **Mistral/Mixtral** 46 + 47 + ## Quick Start 48 + 49 + ```bash 50 + # Build 51 + make build 52 + 53 + # Run (assumes llama.cpp at localhost:8080) 54 + ./git-summarizer --llama-url http://your-llama-server:8080 55 + 56 + # Test with a public repo 57 + curl -X POST http://localhost:8000/summarize \ 58 + -H "Content-Type: application/json" \ 59 + -d '{ 60 + "repo_url": "https://github.com/user/repo.git", 61 + "base": "v1.0.0", 62 + "head": "v1.1.0" 63 + }' 64 + ``` 65 + 66 + ### Configuration 67 + 68 + | Flag | Env Var | Default | Description | 69 + |------|---------|---------|-------------| 70 + | `--llama-url` | `LLAMA_URL` | `http://localhost:8080` | llama.cpp server URL | 71 + | `--model` | `LLAMA_MODEL` | `qwen2.5-coder` | Model name | 72 + | `--listen` | - | `:8000` | Listen address | 73 + | `--repo-dir` | - | `/tmp` | Directory for cloned repos | 74 + | `--max-diff` | - | `16000` | Max diff chars to send to LLM | 75 + 76 + ### Docker 77 + 78 + ```bash 79 + docker build -t git-summarizer . 80 + 81 + docker run -p 8000:8000 \ 82 + -e LLAMA_URL=http://host.docker.internal:8080 \ 83 + git-summarizer 84 + ``` 85 + 86 + ### Kubernetes 87 + 88 + Update `k8s/deployment.yaml` with your llama.cpp service address, then: 89 + 90 + ```bash 91 + kubectl apply -f k8s/deployment.yaml 92 + ``` 93 + 94 + ## API Reference 95 + 96 + ### POST /summarize 97 + 98 + Request body: 99 + ```json 100 + { 101 + "repo_url": "https://github.com/user/repo.git", // Clone from URL 102 + "repo_path": "/local/path", // OR use existing path 103 + "base": "main", // Base ref 104 + "head": "HEAD" // Head ref 105 + } 106 + ``` 107 + 108 + Response: 109 + ```json 110 + { 111 + "summary": "## Summary\n\nThis release includes..." 112 + } 113 + ``` 114 + 115 + ### GET /health 116 + 117 + Returns `200 OK` if healthy. 118 + 119 + ## Tools Available to the LLM 120 + 121 + The LLM can call these tools to explore the repository: 122 + 123 + | Tool | Description | 124 + |------|-------------| 125 + | `git_log` | Get commit log between refs | 126 + | `git_diff` | Get diff (optionally filtered to specific files) | 127 + | `list_changed_files` | List changed files with status | 128 + | `git_show_commit` | Show details of a specific commit | 129 + | `read_file` | Read file contents at a ref | 130 + | `git_diff_stats` | Get stats (files changed, insertions, deletions) | 131 + 132 + ## Example Output 133 + 134 + ``` 135 + ## Summary 136 + 137 + This release (v1.2.0 to v1.3.0) includes 23 commits focusing on performance 138 + improvements and bug fixes. 139 + 140 + ### Key Changes 141 + 142 + **Performance** 143 + - Refactored database query layer to use connection pooling, reducing 144 + latency by ~40% under load 145 + - Added caching for user session data 146 + 147 + **Bug Fixes** 148 + - Fixed race condition in websocket handler that caused dropped messages 149 + - Corrected timezone handling in scheduled tasks 150 + 151 + **Other** 152 + - Updated Go version to 1.22 153 + - Added new health check endpoint 154 + 155 + ### Breaking Changes 156 + 157 + None in this release. 158 + 159 + ### Files Changed 160 + 161 + 47 files changed, 1,203 insertions, 456 deletions. Main areas: 162 + - `pkg/database/` - Connection pooling refactor 163 + - `internal/websocket/` - Race condition fix 164 + - `cmd/server/` - Health check endpoint 165 + ``` 166 + 167 + ## License 168 + 169 + MIT
+35
cmd/git-summarizer/main.go
··· 1 + package main 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "os" 7 + 8 + "git-summarizer/internal/api" 9 + "git-summarizer/internal/summarizer" 10 + "git-summarizer/pkg/cache" 11 + "git-summarizer/pkg/config" 12 + ) 13 + 14 + func main() { 15 + cfg := config.LoadConfig() 16 + 17 + s := summarizer.New(cfg) 18 + repoCache := cache.New(cfg.CacheDir, cfg.MaxCachedRepos) 19 + h := api.NewHandler(s, repoCache) 20 + 21 + http.HandleFunc("/summarize", h.HandleSummarize) 22 + http.HandleFunc("/health", h.HandleHealth) 23 + 24 + slog.Info("starting git-summarizer", 25 + "addr", cfg.ListenAddr, 26 + "llm_url", cfg.LlamaURL, 27 + "model", cfg.Model, 28 + "cache_dir", cfg.CacheDir, 29 + "max_cached_repos", cfg.MaxCachedRepos) 30 + 31 + if err := http.ListenAndServe(cfg.ListenAddr, nil); err != nil { 32 + slog.Error("server failed", "error", err) 33 + os.Exit(1) 34 + } 35 + }
+114
deployment.yaml
··· 1 + apiVersion: v1 2 + kind: Namespace 3 + metadata: 4 + name: git-summarizer 5 + --- 6 + apiVersion: v1 7 + kind: ConfigMap 8 + metadata: 9 + name: git-summarizer-config 10 + namespace: git-summarizer 11 + data: 12 + LLAMA_URL: "http://llamacpp.your-namespace.svc.cluster.local:8080" # Adjust to your llama.cpp service 13 + LLAMA_MODEL: "qwen2.5-coder" 14 + --- 15 + # Optional: Secret for git SSH keys 16 + apiVersion: v1 17 + kind: Secret 18 + metadata: 19 + name: git-ssh-key 20 + namespace: git-summarizer 21 + type: Opaque 22 + data: 23 + # base64 encoded SSH private key 24 + # id_rsa: <base64-encoded-key> 25 + --- 26 + apiVersion: apps/v1 27 + kind: Deployment 28 + metadata: 29 + name: git-summarizer 30 + namespace: git-summarizer 31 + spec: 32 + replicas: 1 33 + selector: 34 + matchLabels: 35 + app: git-summarizer 36 + template: 37 + metadata: 38 + labels: 39 + app: git-summarizer 40 + spec: 41 + containers: 42 + - name: git-summarizer 43 + image: your-registry/git-summarizer:latest 44 + ports: 45 + - containerPort: 8000 46 + envFrom: 47 + - configMapRef: 48 + name: git-summarizer-config 49 + resources: 50 + requests: 51 + memory: "64Mi" 52 + cpu: "100m" 53 + limits: 54 + memory: "256Mi" 55 + cpu: "500m" 56 + livenessProbe: 57 + httpGet: 58 + path: /health 59 + port: 8000 60 + initialDelaySeconds: 5 61 + periodSeconds: 10 62 + readinessProbe: 63 + httpGet: 64 + path: /health 65 + port: 8000 66 + initialDelaySeconds: 5 67 + periodSeconds: 5 68 + volumeMounts: 69 + - name: tmp 70 + mountPath: /tmp 71 + # Uncomment for SSH cloning support 72 + # - name: ssh-key 73 + # mountPath: /root/.ssh/id_rsa 74 + # subPath: id_rsa 75 + # readOnly: true 76 + volumes: 77 + - name: tmp 78 + emptyDir: 79 + sizeLimit: 1Gi 80 + # - name: ssh-key 81 + # secret: 82 + # secretName: git-ssh-key 83 + # defaultMode: 0600 84 + --- 85 + apiVersion: v1 86 + kind: Service 87 + metadata: 88 + name: git-summarizer 89 + namespace: git-summarizer 90 + spec: 91 + selector: 92 + app: git-summarizer 93 + ports: 94 + - port: 8000 95 + targetPort: 8000 96 + --- 97 + # Optional: Ingress for external access 98 + # apiVersion: networking.k8s.io/v1 99 + # kind: Ingress 100 + # metadata: 101 + # name: git-summarizer 102 + # namespace: git-summarizer 103 + # spec: 104 + # rules: 105 + # - host: git-summarizer.your-domain.com 106 + # http: 107 + # paths: 108 + # - path: / 109 + # pathType: Prefix 110 + # backend: 111 + # service: 112 + # name: git-summarizer 113 + # port: 114 + # number: 8000
git-summarizer

This is a binary file and will not be displayed.

+28
go.mod
··· 1 + module git-summarizer 2 + 3 + go 1.25.5 4 + 5 + require github.com/go-git/go-git/v5 v5.16.4 6 + 7 + require ( 8 + dario.cat/mergo v1.0.2 // indirect 9 + github.com/Microsoft/go-winio v0.6.2 // indirect 10 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 11 + github.com/cloudflare/circl v1.6.1 // indirect 12 + github.com/cyphar/filepath-securejoin v0.6.1 // indirect 13 + github.com/emirpasic/gods v1.18.1 // indirect 14 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 15 + github.com/go-git/go-billy/v5 v5.7.0 // indirect 16 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 17 + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 18 + github.com/kevinburke/ssh_config v1.4.0 // indirect 19 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 20 + github.com/pjbgf/sha1cd v0.5.0 // indirect 21 + github.com/sergi/go-diff v1.4.0 // indirect 22 + github.com/skeema/knownhosts v1.3.2 // indirect 23 + github.com/xanzy/ssh-agent v0.3.3 // indirect 24 + golang.org/x/crypto v0.46.0 // indirect 25 + golang.org/x/net v0.48.0 // indirect 26 + golang.org/x/sys v0.39.0 // indirect 27 + gopkg.in/warnings.v0 v0.1.2 // indirect 28 + )
+104
go.sum
··· 1 + dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= 2 + dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 3 + github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 + github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 + github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 + github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 7 + github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 8 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 12 + github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 13 + github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 14 + github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= 15 + github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= 16 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 + github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 20 + github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 21 + github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 22 + github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 23 + github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 24 + github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 25 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 26 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 27 + github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= 28 + github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= 29 + github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 30 + github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 31 + github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= 32 + github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 33 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 34 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 35 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 36 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 37 + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 38 + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 39 + github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= 40 + github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= 41 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 42 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 43 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 44 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 45 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 46 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 47 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 48 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 49 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 50 + github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 51 + github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 52 + github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= 53 + github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= 54 + github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 55 + github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 56 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 57 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 58 + github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 59 + github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 60 + github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= 61 + github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 62 + github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 63 + github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= 64 + github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= 65 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 66 + github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 67 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 68 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 69 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 70 + github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 71 + github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 72 + golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 73 + golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= 74 + golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 75 + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 76 + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 77 + golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 78 + golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= 79 + golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 80 + golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 81 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 82 + golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 83 + golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 84 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 + golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 87 + golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 88 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 89 + golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= 90 + golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 91 + golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 92 + golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 93 + golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 94 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 95 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 96 + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 97 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 98 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 99 + gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 100 + gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 101 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 102 + gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 103 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 104 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+94
internal/api/handlers.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + "path/filepath" 8 + "time" 9 + 10 + "git-summarizer/internal/summarizer" 11 + "git-summarizer/pkg/cache" 12 + "git-summarizer/pkg/git" 13 + "git-summarizer/pkg/llm" 14 + ) 15 + 16 + // Handler holds dependencies for HTTP handlers 17 + type Handler struct { 18 + Summarizer *summarizer.Summarizer 19 + Cache *cache.RepoCache 20 + } 21 + 22 + // NewHandler creates a new Handler 23 + func NewHandler(s *summarizer.Summarizer, c *cache.RepoCache) *Handler { 24 + return &Handler{Summarizer: s, Cache: c} 25 + } 26 + 27 + // HandleSummarize handles POST /summarize requests 28 + func (h *Handler) HandleSummarize(w http.ResponseWriter, r *http.Request) { 29 + if r.Method != http.MethodPost { 30 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 31 + return 32 + } 33 + 34 + var req llm.SummarizeRequest 35 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 36 + http.Error(w, "Invalid request body", http.StatusBadRequest) 37 + return 38 + } 39 + 40 + if req.Base == "" || req.Head == "" { 41 + http.Error(w, "base and head are required", http.StatusBadRequest) 42 + return 43 + } 44 + 45 + var repo *git.Repo 46 + 47 + if req.RepoURL != "" { 48 + // Get from cache or clone 49 + slog.Info("getting repo", "url", req.RepoURL) 50 + cacheStart := time.Now() 51 + var err error 52 + repo, err = h.Cache.GetOrClone(req.RepoURL, h.Summarizer.GetAuth()) 53 + if err != nil { 54 + w.Header().Set("Content-Type", "application/json") 55 + json.NewEncoder(w).Encode(llm.SummarizeResponse{Error: err.Error()}) 56 + return 57 + } 58 + slog.Info("repo ready", "duration", time.Since(cacheStart), "cache_size", h.Cache.Size()) 59 + } else if req.RepoPath != "" { 60 + var err error 61 + repoPath, _ := filepath.Abs(req.RepoPath) 62 + repo, err = git.Open(repoPath) 63 + if err != nil { 64 + w.Header().Set("Content-Type", "application/json") 65 + json.NewEncoder(w).Encode(llm.SummarizeResponse{Error: err.Error()}) 66 + return 67 + } 68 + } else { 69 + http.Error(w, "repo_url or repo_path is required", http.StatusBadRequest) 70 + return 71 + } 72 + 73 + style := req.Style 74 + if style == "" { 75 + style = "detailed" 76 + } 77 + slog.Info("summarizing", "base", req.Base, "head", req.Head, "style", style) 78 + 79 + summary, err := h.Summarizer.Summarize(repo, req.Base, req.Head, style) 80 + if err != nil { 81 + w.Header().Set("Content-Type", "application/json") 82 + json.NewEncoder(w).Encode(llm.SummarizeResponse{Error: err.Error()}) 83 + return 84 + } 85 + 86 + w.Header().Set("Content-Type", "application/json") 87 + json.NewEncoder(w).Encode(llm.SummarizeResponse{Summary: summary}) 88 + } 89 + 90 + // HandleHealth handles GET /health requests 91 + func (h *Handler) HandleHealth(w http.ResponseWriter, r *http.Request) { 92 + w.WriteHeader(http.StatusOK) 93 + w.Write([]byte("ok")) 94 + }
+41
internal/summarizer/prompts.go
··· 1 + package summarizer 2 + 3 + // getSystemPrompt returns the appropriate system prompt for the given style 4 + func getSystemPrompt(style string) string { 5 + switch style { 6 + case "bluesky": 7 + return `You are a git change summarizer. Write a VERY brief summary for a Bluesky post. 8 + 9 + STRICT RULES: 10 + - MAXIMUM 300 characters (this is a hard limit, count carefully!) 11 + - Focus ONLY on user-facing changes 12 + - Skip internal/technical/refactoring changes 13 + - One or two short sentences only 14 + - No bullet points, no markdown, no emojis 15 + - Start with version if available 16 + 17 + Example (59 chars): "Added dark mode, faster search, and fixed mobile login bug."` 18 + 19 + case "short": 20 + return `You are a git change summarizer. Write a brief summary of the changes. 21 + 22 + Rules: 23 + - Keep it under 500 characters 24 + - Focus on the most important changes 25 + - Use 2-4 bullet points max 26 + - Skip minor/internal changes` 27 + 28 + default: // "detailed" 29 + return `You are a git change summarizer. Analyze changes between git refs and provide clear summaries. 30 + 31 + You have been provided with the commit log, changed files, and diff stats. Use the tools only if you need additional detail (e.g., specific file diffs or file contents). 32 + 33 + Provide a summary with: 34 + - High-level overview of what changed and why 35 + - Key modifications grouped by area/purpose 36 + - Breaking changes or important notes 37 + - Notable additions or removals 38 + 39 + Be efficient - often you can summarize directly from the provided context without any tool calls.` 40 + } 41 + }
+310
internal/summarizer/summarizer.go
··· 1 + package summarizer 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "net/http" 10 + "regexp" 11 + "strings" 12 + "time" 13 + 14 + "git-summarizer/pkg/config" 15 + "git-summarizer/pkg/git" 16 + "git-summarizer/pkg/llm" 17 + 18 + "github.com/go-git/go-git/v5/plumbing/transport" 19 + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" 20 + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" 21 + ) 22 + 23 + // Summarizer handles the orchestration of git summarization 24 + type Summarizer struct { 25 + Config config.Config 26 + client *http.Client 27 + } 28 + 29 + // New creates a new Summarizer 30 + func New(cfg config.Config) *Summarizer { 31 + return &Summarizer{ 32 + Config: cfg, 33 + client: &http.Client{Timeout: 120 * time.Second}, 34 + } 35 + } 36 + 37 + // GetAuth returns appropriate auth method based on config 38 + func (s *Summarizer) GetAuth() transport.AuthMethod { 39 + if s.Config.GitToken != "" { 40 + return &githttp.BasicAuth{ 41 + Username: s.Config.GitUser, 42 + Password: s.Config.GitToken, 43 + } 44 + } 45 + if s.Config.SSHKeyPath != "" { 46 + auth, err := gitssh.NewPublicKeysFromFile("git", s.Config.SSHKeyPath, "") 47 + if err == nil { 48 + return auth 49 + } 50 + slog.Warn("failed to load SSH key", "error", err) 51 + } 52 + return nil 53 + } 54 + 55 + // executeTool runs a tool and returns the result 56 + func (s *Summarizer) executeTool(repo *git.Repo, name string, args map[string]interface{}) string { 57 + var result string 58 + var err error 59 + 60 + switch name { 61 + case "git_log": 62 + base := args["base"].(string) 63 + head := args["head"].(string) 64 + maxCount := 50 65 + if mc, ok := args["max_count"].(float64); ok { 66 + maxCount = int(mc) 67 + } 68 + result, err = repo.GetLog(base, head, maxCount) 69 + 70 + case "git_diff": 71 + base := args["base"].(string) 72 + head := args["head"].(string) 73 + var files []string 74 + if f, ok := args["files"].([]interface{}); ok { 75 + for _, file := range f { 76 + files = append(files, file.(string)) 77 + } 78 + } 79 + result, err = repo.GetDiff(base, head, files) 80 + 81 + case "list_changed_files": 82 + base := args["base"].(string) 83 + head := args["head"].(string) 84 + result, err = repo.ListChangedFiles(base, head) 85 + 86 + case "git_show_commit": 87 + ref := args["ref"].(string) 88 + result, err = repo.ShowCommit(ref) 89 + 90 + case "read_file": 91 + path := args["path"].(string) 92 + ref := "" 93 + if r, ok := args["ref"].(string); ok { 94 + ref = r 95 + } 96 + result, err = repo.ReadFile(path, ref) 97 + 98 + case "git_diff_stats": 99 + base := args["base"].(string) 100 + head := args["head"].(string) 101 + result, err = repo.GetDiffStats(base, head) 102 + 103 + default: 104 + return fmt.Sprintf("Unknown tool: %s", name) 105 + } 106 + 107 + if err != nil { 108 + return fmt.Sprintf("Error: %v", err) 109 + } 110 + 111 + // Truncate large outputs 112 + if len(result) > s.Config.MaxDiffLen { 113 + result = result[:s.Config.MaxDiffLen] + "\n... [truncated]" 114 + } 115 + 116 + return result 117 + } 118 + 119 + // parseXMLToolCalls extracts tool calls from XML-style format in message content 120 + // Handles: <function=name><parameter=key>value</parameter></function> 121 + func parseXMLToolCalls(content string) []llm.ToolCall { 122 + var calls []llm.ToolCall 123 + 124 + // Match <function=name>...</function> 125 + funcRe := regexp.MustCompile(`<function=(\w+)>([\s\S]*?)</function>`) 126 + paramRe := regexp.MustCompile(`<parameter=(\w+)>([\s\S]*?)</parameter>`) 127 + 128 + matches := funcRe.FindAllStringSubmatch(content, -1) 129 + for i, match := range matches { 130 + if len(match) < 3 { 131 + continue 132 + } 133 + 134 + funcName := match[1] 135 + funcBody := match[2] 136 + 137 + args := make(map[string]interface{}) 138 + paramMatches := paramRe.FindAllStringSubmatch(funcBody, -1) 139 + for _, pm := range paramMatches { 140 + if len(pm) >= 3 { 141 + args[pm[1]] = strings.TrimSpace(pm[2]) 142 + } 143 + } 144 + 145 + argsJSON, _ := json.Marshal(args) 146 + calls = append(calls, llm.ToolCall{ 147 + ID: fmt.Sprintf("call_%d", i), 148 + Type: "function", 149 + Function: llm.FunctionCall{ 150 + Name: funcName, 151 + Arguments: string(argsJSON), 152 + }, 153 + }) 154 + } 155 + 156 + return calls 157 + } 158 + 159 + // chat sends a request to the LLM 160 + func (s *Summarizer) chat(req llm.ChatRequest) (*llm.ChatResponse, error) { 161 + body, err := json.Marshal(req) 162 + if err != nil { 163 + return nil, err 164 + } 165 + 166 + resp, err := s.client.Post( 167 + s.Config.LlamaURL+"/v1/chat/completions", 168 + "application/json", 169 + bytes.NewReader(body), 170 + ) 171 + if err != nil { 172 + return nil, err 173 + } 174 + defer resp.Body.Close() 175 + 176 + respBody, err := io.ReadAll(resp.Body) 177 + if err != nil { 178 + return nil, err 179 + } 180 + 181 + var chatResp llm.ChatResponse 182 + if err := json.Unmarshal(respBody, &chatResp); err != nil { 183 + return nil, fmt.Errorf("failed to parse response: %s", string(respBody)) 184 + } 185 + 186 + if chatResp.Error != nil { 187 + return nil, fmt.Errorf("API error: %s", chatResp.Error.Message) 188 + } 189 + 190 + return &chatResp, nil 191 + } 192 + 193 + // Summarize runs the agentic loop to summarize changes 194 + func (s *Summarizer) Summarize(repo *git.Repo, base, head, style string) (string, error) { 195 + systemPrompt := getSystemPrompt(style) 196 + 197 + // Pre-fetch context to reduce tool calls 198 + slog.Info("fetching initial context") 199 + contextStart := time.Now() 200 + 201 + logStart := time.Now() 202 + commitLog, err := repo.GetLog(base, head, 100) 203 + if err != nil { 204 + commitLog = fmt.Sprintf("Error fetching log: %v", err) 205 + } 206 + slog.Debug("git_log", "duration", time.Since(logStart)) 207 + 208 + filesStart := time.Now() 209 + changedFiles, err := repo.ListChangedFiles(base, head) 210 + if err != nil { 211 + changedFiles = fmt.Sprintf("Error fetching files: %v", err) 212 + } 213 + slog.Debug("list_changed_files", "duration", time.Since(filesStart)) 214 + 215 + statsStart := time.Now() 216 + diffStats, err := repo.GetDiffStats(base, head) 217 + if err != nil { 218 + diffStats = fmt.Sprintf("Error fetching stats: %v", err) 219 + } 220 + slog.Debug("git_diff_stats", "duration", time.Since(statsStart)) 221 + slog.Info("context fetched", "duration", time.Since(contextStart)) 222 + 223 + userPrompt := fmt.Sprintf(`Please summarize the changes between '%s' and '%s' in this repository. 224 + 225 + ## Commit Log 226 + %s 227 + 228 + ## Changed Files 229 + %s 230 + 231 + ## Diff Stats 232 + %s 233 + 234 + Use the available tools if you need to examine specific file diffs or read file contents for more context.`, 235 + base, head, commitLog, changedFiles, diffStats) 236 + 237 + messages := []llm.Message{ 238 + {Role: "system", Content: systemPrompt}, 239 + {Role: "user", Content: userPrompt}, 240 + } 241 + 242 + maxIterations := 10 243 + for i := 0; i < maxIterations; i++ { 244 + slog.Info("sending request to LLM", "iteration", i+1) 245 + 246 + llmStart := time.Now() 247 + resp, err := s.chat(llm.ChatRequest{ 248 + Model: s.Config.Model, 249 + Messages: messages, 250 + Tools: llm.Tools, 251 + ToolChoice: "auto", 252 + }) 253 + if err != nil { 254 + return "", fmt.Errorf("LLM request failed: %w", err) 255 + } 256 + slog.Info("LLM responded", "duration", time.Since(llmStart)) 257 + 258 + if len(resp.Choices) == 0 { 259 + return "", fmt.Errorf("no response from LLM") 260 + } 261 + 262 + msg := resp.Choices[0].Message 263 + 264 + // Check for XML-style tool calls in content if no native tool calls 265 + if len(msg.ToolCalls) == 0 && strings.Contains(msg.Content, "<function=") { 266 + slog.Debug("parsing XML-style tool calls from content") 267 + msg.ToolCalls = parseXMLToolCalls(msg.Content) 268 + // Strip the XML from content for cleaner logs 269 + funcRe := regexp.MustCompile(`<function=\w+>[\s\S]*?</function>`) 270 + msg.Content = strings.TrimSpace(funcRe.ReplaceAllString(msg.Content, "")) 271 + } 272 + 273 + messages = append(messages, msg) 274 + 275 + // If no tool calls, we're done 276 + if len(msg.ToolCalls) == 0 { 277 + slog.Info("completed", "iterations", i+1) 278 + return msg.Content, nil 279 + } 280 + 281 + // Execute tool calls 282 + for _, tc := range msg.ToolCalls { 283 + var args map[string]interface{} 284 + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { 285 + args = make(map[string]interface{}) 286 + } 287 + 288 + argsJSON, _ := json.Marshal(args) 289 + slog.Info("executing tool", "name", tc.Function.Name, "args", string(argsJSON)) 290 + 291 + result := s.executeTool(repo, tc.Function.Name, args) 292 + 293 + // Log result summary 294 + lines := strings.Count(result, "\n") 295 + if len(result) > 200 { 296 + slog.Debug("tool result", "bytes", len(result), "lines", lines) 297 + } else { 298 + slog.Debug("tool result", "output", strings.ReplaceAll(result, "\n", "\\n")) 299 + } 300 + 301 + messages = append(messages, llm.Message{ 302 + Role: "tool", 303 + ToolCallID: tc.ID, 304 + Content: result, 305 + }) 306 + } 307 + } 308 + 309 + return "", fmt.Errorf("max iterations reached without completion") 310 + }
+183
pkg/cache/cache.go
··· 1 + package cache 2 + 3 + import ( 4 + "container/list" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "fmt" 8 + "log/slog" 9 + "os" 10 + "path/filepath" 11 + "sync" 12 + 13 + "git-summarizer/pkg/git" 14 + 15 + "github.com/go-git/go-git/v5/plumbing/transport" 16 + ) 17 + 18 + // RepoCache manages cached git repositories with LRU eviction 19 + type RepoCache struct { 20 + baseDir string 21 + maxRepos int 22 + mu sync.Mutex 23 + repoLocks map[string]*sync.Mutex 24 + lru *list.List 25 + repos map[string]*cacheEntry 26 + } 27 + 28 + type cacheEntry struct { 29 + url string 30 + path string 31 + element *list.Element 32 + } 33 + 34 + // New creates a new RepoCache 35 + func New(baseDir string, maxRepos int) *RepoCache { 36 + return &RepoCache{ 37 + baseDir: baseDir, 38 + maxRepos: maxRepos, 39 + repoLocks: make(map[string]*sync.Mutex), 40 + lru: list.New(), 41 + repos: make(map[string]*cacheEntry), 42 + } 43 + } 44 + 45 + // GetOrClone returns a cached repo or clones it if not present 46 + // If the repo is cached, it fetches updates before returning 47 + func (c *RepoCache) GetOrClone(url string, auth transport.AuthMethod) (*git.Repo, error) { 48 + // Get or create per-repo lock 49 + repoLock := c.getRepoLock(url) 50 + repoLock.Lock() 51 + defer repoLock.Unlock() 52 + 53 + // Check if repo exists in cache 54 + c.mu.Lock() 55 + entry, exists := c.repos[url] 56 + if exists { 57 + // Move to front of LRU 58 + c.lru.MoveToFront(entry.element) 59 + c.mu.Unlock() 60 + 61 + // Fetch updates 62 + slog.Info("fetching cached repo", "url", url) 63 + repo, err := git.Open(entry.path) 64 + if err != nil { 65 + // Cache entry invalid, remove and re-clone 66 + slog.Warn("cached repo invalid, re-cloning", "url", url, "error", err) 67 + c.mu.Lock() 68 + c.removeEntry(url) 69 + c.mu.Unlock() 70 + return c.cloneNew(url, auth) 71 + } 72 + 73 + if err := repo.Fetch(auth); err != nil { 74 + slog.Warn("fetch failed", "url", url, "error", err) 75 + // Continue with potentially stale data rather than failing 76 + } 77 + 78 + return repo, nil 79 + } 80 + c.mu.Unlock() 81 + 82 + // Clone new repo 83 + return c.cloneNew(url, auth) 84 + } 85 + 86 + // cloneNew clones a repo and adds it to the cache 87 + func (c *RepoCache) cloneNew(url string, auth transport.AuthMethod) (*git.Repo, error) { 88 + c.mu.Lock() 89 + 90 + // Evict if at capacity 91 + for c.lru.Len() >= c.maxRepos { 92 + c.evictLRU() 93 + } 94 + 95 + // Prepare cache path 96 + path := c.urlToPath(url) 97 + c.mu.Unlock() 98 + 99 + // Ensure cache directory exists 100 + if err := os.MkdirAll(c.baseDir, 0755); err != nil { 101 + return nil, fmt.Errorf("failed to create cache dir: %w", err) 102 + } 103 + 104 + // Clone 105 + slog.Info("cloning repo to cache", "url", url, "path", path) 106 + repo, err := git.Clone(url, path, auth) 107 + if err != nil { 108 + return nil, err 109 + } 110 + 111 + // Add to cache 112 + c.mu.Lock() 113 + entry := &cacheEntry{ 114 + url: url, 115 + path: path, 116 + } 117 + entry.element = c.lru.PushFront(url) 118 + c.repos[url] = entry 119 + c.mu.Unlock() 120 + 121 + slog.Info("repo cached", "url", url, "cache_size", c.lru.Len()) 122 + return repo, nil 123 + } 124 + 125 + // getRepoLock returns the lock for a specific repo URL 126 + func (c *RepoCache) getRepoLock(url string) *sync.Mutex { 127 + c.mu.Lock() 128 + defer c.mu.Unlock() 129 + 130 + lock, exists := c.repoLocks[url] 131 + if !exists { 132 + lock = &sync.Mutex{} 133 + c.repoLocks[url] = lock 134 + } 135 + return lock 136 + } 137 + 138 + // evictLRU removes the least recently used repo from the cache 139 + // Must be called with c.mu held 140 + func (c *RepoCache) evictLRU() { 141 + elem := c.lru.Back() 142 + if elem == nil { 143 + return 144 + } 145 + 146 + url := elem.Value.(string) 147 + c.removeEntry(url) 148 + slog.Info("evicted repo from cache", "url", url) 149 + } 150 + 151 + // removeEntry removes a repo from the cache 152 + // Must be called with c.mu held 153 + func (c *RepoCache) removeEntry(url string) { 154 + entry, exists := c.repos[url] 155 + if !exists { 156 + return 157 + } 158 + 159 + // Remove from LRU list 160 + c.lru.Remove(entry.element) 161 + 162 + // Remove from map 163 + delete(c.repos, url) 164 + 165 + // Remove from disk 166 + if err := os.RemoveAll(entry.path); err != nil { 167 + slog.Warn("failed to remove cached repo", "path", entry.path, "error", err) 168 + } 169 + } 170 + 171 + // urlToPath converts a repo URL to a filesystem-safe path 172 + func (c *RepoCache) urlToPath(url string) string { 173 + hash := sha256.Sum256([]byte(url)) 174 + hashStr := hex.EncodeToString(hash[:8]) // Use first 8 bytes (16 hex chars) 175 + return filepath.Join(c.baseDir, hashStr) 176 + } 177 + 178 + // Size returns the number of cached repos 179 + func (c *RepoCache) Size() int { 180 + c.mu.Lock() 181 + defer c.mu.Unlock() 182 + return c.lru.Len() 183 + }
+67
pkg/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "flag" 5 + "os" 6 + ) 7 + 8 + // Config holds application configuration 9 + type Config struct { 10 + LlamaURL string 11 + Model string 12 + ListenAddr string 13 + RepoDir string 14 + MaxDiffLen int 15 + GitUser string 16 + GitToken string 17 + SSHKeyPath string 18 + CacheDir string 19 + MaxCachedRepos int 20 + } 21 + 22 + // LoadConfig parses flags and environment variables to build configuration 23 + func LoadConfig() Config { 24 + config := Config{ 25 + LlamaURL: "http://localhost:8080", 26 + Model: "qwen2.5-coder", 27 + ListenAddr: ":8000", 28 + RepoDir: "/tmp", 29 + MaxDiffLen: 16000, 30 + CacheDir: "/tmp/git-cache", 31 + MaxCachedRepos: 50, 32 + } 33 + 34 + flag.StringVar(&config.LlamaURL, "llama-url", config.LlamaURL, "llama.cpp server URL") 35 + flag.StringVar(&config.Model, "model", config.Model, "Model name to use") 36 + flag.StringVar(&config.ListenAddr, "listen", config.ListenAddr, "Listen address") 37 + flag.StringVar(&config.RepoDir, "repo-dir", config.RepoDir, "Directory for cloned repos") 38 + flag.IntVar(&config.MaxDiffLen, "max-diff", config.MaxDiffLen, "Max diff length to send to LLM") 39 + flag.StringVar(&config.GitUser, "git-user", "", "Git username for HTTPS auth") 40 + flag.StringVar(&config.GitToken, "git-token", "", "Git token/password for HTTPS auth") 41 + flag.StringVar(&config.SSHKeyPath, "ssh-key", "", "Path to SSH private key") 42 + flag.StringVar(&config.CacheDir, "cache-dir", config.CacheDir, "Directory for cached repos") 43 + flag.IntVar(&config.MaxCachedRepos, "max-cached-repos", config.MaxCachedRepos, "Max repos to cache (LRU eviction)") 44 + flag.Parse() 45 + 46 + // Environment variable overrides 47 + if url := os.Getenv("LLAMA_URL"); url != "" { 48 + config.LlamaURL = url 49 + } 50 + if model := os.Getenv("LLAMA_MODEL"); model != "" { 51 + config.Model = model 52 + } 53 + if user := os.Getenv("GIT_USER"); user != "" { 54 + config.GitUser = user 55 + } 56 + if token := os.Getenv("GIT_TOKEN"); token != "" { 57 + config.GitToken = token 58 + } 59 + if key := os.Getenv("SSH_KEY_PATH"); key != "" { 60 + config.SSHKeyPath = key 61 + } 62 + if dir := os.Getenv("CACHE_DIR"); dir != "" { 63 + config.CacheDir = dir 64 + } 65 + 66 + return config 67 + }
+437
pkg/git/repo.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/go-git/go-git/v5" 9 + "github.com/go-git/go-git/v5/plumbing" 10 + "github.com/go-git/go-git/v5/plumbing/object" 11 + "github.com/go-git/go-git/v5/plumbing/transport" 12 + ) 13 + 14 + // Repo wraps go-git repository operations 15 + type Repo struct { 16 + repo *git.Repository 17 + path string 18 + } 19 + 20 + // Open opens an existing git repository 21 + func Open(path string) (*Repo, error) { 22 + repo, err := git.PlainOpen(path) 23 + if err != nil { 24 + return nil, fmt.Errorf("failed to open repo: %w", err) 25 + } 26 + return &Repo{repo: repo, path: path}, nil 27 + } 28 + 29 + // Clone clones a repository 30 + func Clone(url, dest string, auth transport.AuthMethod) (*Repo, error) { 31 + repo, err := git.PlainClone(dest, false, &git.CloneOptions{ 32 + URL: url, 33 + Auth: auth, 34 + Progress: nil, 35 + Tags: git.AllTags, 36 + }) 37 + if err != nil { 38 + return nil, fmt.Errorf("failed to clone: %w", err) 39 + } 40 + return &Repo{repo: repo, path: dest}, nil 41 + } 42 + 43 + // Fetch fetches updates from the remote 44 + func (g *Repo) Fetch(auth transport.AuthMethod) error { 45 + err := g.repo.Fetch(&git.FetchOptions{ 46 + Auth: auth, 47 + Tags: git.AllTags, 48 + Force: true, 49 + }) 50 + // "already up-to-date" is not an error 51 + if err != nil && err != git.NoErrAlreadyUpToDate { 52 + return fmt.Errorf("failed to fetch: %w", err) 53 + } 54 + return nil 55 + } 56 + 57 + // resolveRef resolves a ref string to a commit hash 58 + func (g *Repo) resolveRef(refStr string) (*plumbing.Hash, error) { 59 + // Try as a branch 60 + ref, err := g.repo.Reference(plumbing.NewBranchReferenceName(refStr), true) 61 + if err == nil { 62 + h := ref.Hash() 63 + return &h, nil 64 + } 65 + 66 + // Try as a tag 67 + ref, err = g.repo.Reference(plumbing.NewTagReferenceName(refStr), true) 68 + if err == nil { 69 + // Could be annotated tag, resolve to commit 70 + h := ref.Hash() 71 + tagObj, err := g.repo.TagObject(h) 72 + if err == nil { 73 + commit, err := tagObj.Commit() 74 + if err == nil { 75 + ch := commit.Hash 76 + return &ch, nil 77 + } 78 + } 79 + return &h, nil 80 + } 81 + 82 + // Try as a remote branch 83 + ref, err = g.repo.Reference(plumbing.NewRemoteReferenceName("origin", refStr), true) 84 + if err == nil { 85 + h := ref.Hash() 86 + return &h, nil 87 + } 88 + 89 + // Try HEAD 90 + if refStr == "HEAD" { 91 + ref, err := g.repo.Head() 92 + if err == nil { 93 + h := ref.Hash() 94 + return &h, nil 95 + } 96 + } 97 + 98 + // Try as direct hash 99 + if len(refStr) >= 4 { 100 + h := plumbing.NewHash(refStr) 101 + if _, err := g.repo.CommitObject(h); err == nil { 102 + return &h, nil 103 + } 104 + } 105 + 106 + // Try revision parsing (HEAD~n, etc) 107 + hash, err := g.repo.ResolveRevision(plumbing.Revision(refStr)) 108 + if err == nil { 109 + return hash, nil 110 + } 111 + 112 + return nil, fmt.Errorf("cannot resolve ref: %s", refStr) 113 + } 114 + 115 + // GetLog returns commit log between two refs 116 + func (g *Repo) GetLog(baseRef, headRef string, maxCount int) (string, error) { 117 + baseHash, err := g.resolveRef(baseRef) 118 + if err != nil { 119 + return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err) 120 + } 121 + 122 + headHash, err := g.resolveRef(headRef) 123 + if err != nil { 124 + return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err) 125 + } 126 + 127 + // Get commits reachable from head 128 + headCommit, err := g.repo.CommitObject(*headHash) 129 + if err != nil { 130 + return "", err 131 + } 132 + 133 + // Collect commits between base and head 134 + var commits []*object.Commit 135 + seen := make(map[plumbing.Hash]bool) 136 + 137 + err = object.NewCommitIterCTime(headCommit, seen, nil).ForEach(func(c *object.Commit) error { 138 + if c.Hash == *baseHash { 139 + return fmt.Errorf("stop") // Use error to stop iteration 140 + } 141 + commits = append(commits, c) 142 + if maxCount > 0 && len(commits) >= maxCount { 143 + return fmt.Errorf("stop") 144 + } 145 + return nil 146 + }) 147 + // Ignore the "stop" error 148 + if err != nil && err.Error() != "stop" { 149 + return "", err 150 + } 151 + 152 + var buf strings.Builder 153 + for _, c := range commits { 154 + shortHash := c.Hash.String()[:7] 155 + firstLine := strings.Split(c.Message, "\n")[0] 156 + buf.WriteString(fmt.Sprintf("%s %s\n", shortHash, firstLine)) 157 + } 158 + 159 + if buf.Len() == 0 { 160 + return "No commits found between refs", nil 161 + } 162 + 163 + return buf.String(), nil 164 + } 165 + 166 + // GetDiff returns the diff between two refs 167 + func (g *Repo) GetDiff(baseRef, headRef string, filterFiles []string) (string, error) { 168 + baseHash, err := g.resolveRef(baseRef) 169 + if err != nil { 170 + return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err) 171 + } 172 + 173 + headHash, err := g.resolveRef(headRef) 174 + if err != nil { 175 + return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err) 176 + } 177 + 178 + baseCommit, err := g.repo.CommitObject(*baseHash) 179 + if err != nil { 180 + return "", err 181 + } 182 + 183 + headCommit, err := g.repo.CommitObject(*headHash) 184 + if err != nil { 185 + return "", err 186 + } 187 + 188 + baseTree, err := baseCommit.Tree() 189 + if err != nil { 190 + return "", err 191 + } 192 + 193 + headTree, err := headCommit.Tree() 194 + if err != nil { 195 + return "", err 196 + } 197 + 198 + changes, err := baseTree.Diff(headTree) 199 + if err != nil { 200 + return "", err 201 + } 202 + 203 + var buf strings.Builder 204 + filterMap := make(map[string]bool) 205 + for _, f := range filterFiles { 206 + filterMap[f] = true 207 + } 208 + 209 + for _, change := range changes { 210 + // Apply file filter if specified 211 + if len(filterFiles) > 0 { 212 + name := change.To.Name 213 + if name == "" { 214 + name = change.From.Name 215 + } 216 + if !filterMap[name] { 217 + continue 218 + } 219 + } 220 + 221 + patch, err := change.Patch() 222 + if err != nil { 223 + continue 224 + } 225 + buf.WriteString(patch.String()) 226 + buf.WriteString("\n") 227 + } 228 + 229 + if buf.Len() == 0 { 230 + return "No changes found", nil 231 + } 232 + 233 + return buf.String(), nil 234 + } 235 + 236 + // ListChangedFiles returns a list of changed files between refs 237 + func (g *Repo) ListChangedFiles(baseRef, headRef string) (string, error) { 238 + baseHash, err := g.resolveRef(baseRef) 239 + if err != nil { 240 + return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err) 241 + } 242 + 243 + headHash, err := g.resolveRef(headRef) 244 + if err != nil { 245 + return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err) 246 + } 247 + 248 + baseCommit, err := g.repo.CommitObject(*baseHash) 249 + if err != nil { 250 + return "", err 251 + } 252 + 253 + headCommit, err := g.repo.CommitObject(*headHash) 254 + if err != nil { 255 + return "", err 256 + } 257 + 258 + baseTree, err := baseCommit.Tree() 259 + if err != nil { 260 + return "", err 261 + } 262 + 263 + headTree, err := headCommit.Tree() 264 + if err != nil { 265 + return "", err 266 + } 267 + 268 + changes, err := baseTree.Diff(headTree) 269 + if err != nil { 270 + return "", err 271 + } 272 + 273 + var buf strings.Builder 274 + for _, change := range changes { 275 + action := "M" // Modified 276 + name := change.To.Name 277 + 278 + if change.From.Name == "" { 279 + action = "A" // Added 280 + } else if change.To.Name == "" { 281 + action = "D" // Deleted 282 + name = change.From.Name 283 + } else if change.From.Name != change.To.Name { 284 + action = "R" // Renamed 285 + name = fmt.Sprintf("%s -> %s", change.From.Name, change.To.Name) 286 + } 287 + 288 + buf.WriteString(fmt.Sprintf("%s\t%s\n", action, name)) 289 + } 290 + 291 + if buf.Len() == 0 { 292 + return "No files changed", nil 293 + } 294 + 295 + return buf.String(), nil 296 + } 297 + 298 + // GetDiffStats returns statistics about changes 299 + func (g *Repo) GetDiffStats(baseRef, headRef string) (string, error) { 300 + baseHash, err := g.resolveRef(baseRef) 301 + if err != nil { 302 + return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err) 303 + } 304 + 305 + headHash, err := g.resolveRef(headRef) 306 + if err != nil { 307 + return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err) 308 + } 309 + 310 + baseCommit, err := g.repo.CommitObject(*baseHash) 311 + if err != nil { 312 + return "", err 313 + } 314 + 315 + headCommit, err := g.repo.CommitObject(*headHash) 316 + if err != nil { 317 + return "", err 318 + } 319 + 320 + baseTree, err := baseCommit.Tree() 321 + if err != nil { 322 + return "", err 323 + } 324 + 325 + headTree, err := headCommit.Tree() 326 + if err != nil { 327 + return "", err 328 + } 329 + 330 + changes, err := baseTree.Diff(headTree) 331 + if err != nil { 332 + return "", err 333 + } 334 + 335 + patch, err := changes.Patch() 336 + if err != nil { 337 + return "", err 338 + } 339 + 340 + stats := patch.Stats() 341 + 342 + var buf strings.Builder 343 + totalAdd, totalDel := 0, 0 344 + 345 + for _, stat := range stats { 346 + buf.WriteString(fmt.Sprintf("%s | %d + %d -\n", stat.Name, stat.Addition, stat.Deletion)) 347 + totalAdd += stat.Addition 348 + totalDel += stat.Deletion 349 + } 350 + 351 + buf.WriteString(fmt.Sprintf("\n%d files changed, %d insertions(+), %d deletions(-)\n", 352 + len(stats), totalAdd, totalDel)) 353 + 354 + return buf.String(), nil 355 + } 356 + 357 + // ShowCommit shows details of a specific commit 358 + func (g *Repo) ShowCommit(refStr string) (string, error) { 359 + hash, err := g.resolveRef(refStr) 360 + if err != nil { 361 + return "", fmt.Errorf("cannot resolve ref '%s': %w", refStr, err) 362 + } 363 + 364 + commit, err := g.repo.CommitObject(*hash) 365 + if err != nil { 366 + return "", err 367 + } 368 + 369 + var buf strings.Builder 370 + buf.WriteString(fmt.Sprintf("commit %s\n", commit.Hash.String())) 371 + buf.WriteString(fmt.Sprintf("Author: %s <%s>\n", commit.Author.Name, commit.Author.Email)) 372 + buf.WriteString(fmt.Sprintf("Date: %s\n\n", commit.Author.When.Format(time.RFC1123))) 373 + 374 + // Indent message 375 + for _, line := range strings.Split(commit.Message, "\n") { 376 + buf.WriteString(fmt.Sprintf(" %s\n", line)) 377 + } 378 + 379 + // Get stats if parent exists 380 + if commit.NumParents() > 0 { 381 + parent, err := commit.Parent(0) 382 + if err == nil { 383 + parentTree, _ := parent.Tree() 384 + commitTree, _ := commit.Tree() 385 + if parentTree != nil && commitTree != nil { 386 + changes, err := parentTree.Diff(commitTree) 387 + if err == nil { 388 + patch, err := changes.Patch() 389 + if err == nil { 390 + stats := patch.Stats() 391 + buf.WriteString("\n") 392 + for _, stat := range stats { 393 + buf.WriteString(fmt.Sprintf(" %s | %d +%d -%d\n", 394 + stat.Name, stat.Addition+stat.Deletion, stat.Addition, stat.Deletion)) 395 + } 396 + } 397 + } 398 + } 399 + } 400 + } 401 + 402 + return buf.String(), nil 403 + } 404 + 405 + // ReadFile reads a file at a specific ref 406 + func (g *Repo) ReadFile(path, refStr string) (string, error) { 407 + if refStr == "" { 408 + refStr = "HEAD" 409 + } 410 + 411 + hash, err := g.resolveRef(refStr) 412 + if err != nil { 413 + return "", fmt.Errorf("cannot resolve ref '%s': %w", refStr, err) 414 + } 415 + 416 + commit, err := g.repo.CommitObject(*hash) 417 + if err != nil { 418 + return "", err 419 + } 420 + 421 + tree, err := commit.Tree() 422 + if err != nil { 423 + return "", err 424 + } 425 + 426 + file, err := tree.File(path) 427 + if err != nil { 428 + return "", fmt.Errorf("file not found: %s", path) 429 + } 430 + 431 + content, err := file.Contents() 432 + if err != nil { 433 + return "", err 434 + } 435 + 436 + return content, nil 437 + }
+96
pkg/llm/tools.go
··· 1 + package llm 2 + 3 + // Tools defines the available tools for the LLM 4 + var Tools = []Tool{ 5 + { 6 + Type: "function", 7 + Function: FunctionDef{ 8 + Name: "git_log", 9 + Description: "Get commit log between two refs with commit messages", 10 + Parameters: Parameters{ 11 + Type: "object", 12 + Properties: map[string]Property{ 13 + "base": {Type: "string", Description: "Base ref (e.g. main, v1.0.0, HEAD~10)"}, 14 + "head": {Type: "string", Description: "Head ref (e.g. HEAD, feature-branch)"}, 15 + "max_count": {Type: "integer", Description: "Max commits to return (default 50)"}, 16 + }, 17 + Required: []string{"base", "head"}, 18 + }, 19 + }, 20 + }, 21 + { 22 + Type: "function", 23 + Function: FunctionDef{ 24 + Name: "git_diff", 25 + Description: "Get the diff between two refs. Can optionally filter to specific files.", 26 + Parameters: Parameters{ 27 + Type: "object", 28 + Properties: map[string]Property{ 29 + "base": {Type: "string", Description: "Base ref"}, 30 + "head": {Type: "string", Description: "Head ref"}, 31 + "files": {Type: "array", Description: "Optional list of files to diff", Items: &Items{Type: "string"}}, 32 + }, 33 + Required: []string{"base", "head"}, 34 + }, 35 + }, 36 + }, 37 + { 38 + Type: "function", 39 + Function: FunctionDef{ 40 + Name: "list_changed_files", 41 + Description: "List all files changed between two refs with their status (added/modified/deleted)", 42 + Parameters: Parameters{ 43 + Type: "object", 44 + Properties: map[string]Property{ 45 + "base": {Type: "string", Description: "Base ref"}, 46 + "head": {Type: "string", Description: "Head ref"}, 47 + }, 48 + Required: []string{"base", "head"}, 49 + }, 50 + }, 51 + }, 52 + { 53 + Type: "function", 54 + Function: FunctionDef{ 55 + Name: "git_show_commit", 56 + Description: "Show details of a specific commit including message and stats", 57 + Parameters: Parameters{ 58 + Type: "object", 59 + Properties: map[string]Property{ 60 + "ref": {Type: "string", Description: "Commit ref (hash, tag, branch)"}, 61 + }, 62 + Required: []string{"ref"}, 63 + }, 64 + }, 65 + }, 66 + { 67 + Type: "function", 68 + Function: FunctionDef{ 69 + Name: "read_file", 70 + Description: "Read contents of a file at a specific ref", 71 + Parameters: Parameters{ 72 + Type: "object", 73 + Properties: map[string]Property{ 74 + "path": {Type: "string", Description: "File path relative to repo root"}, 75 + "ref": {Type: "string", Description: "Git ref (default HEAD)"}, 76 + }, 77 + Required: []string{"path"}, 78 + }, 79 + }, 80 + }, 81 + { 82 + Type: "function", 83 + Function: FunctionDef{ 84 + Name: "git_diff_stats", 85 + Description: "Get diff statistics (files changed, insertions, deletions) between two refs", 86 + Parameters: Parameters{ 87 + Type: "object", 88 + Properties: map[string]Property{ 89 + "base": {Type: "string", Description: "Base ref"}, 90 + "head": {Type: "string", Description: "Head ref"}, 91 + }, 92 + Required: []string{"base", "head"}, 93 + }, 94 + }, 95 + }, 96 + }
+94
pkg/llm/types.go
··· 1 + package llm 2 + 3 + // Tool represents an OpenAI-compatible tool definition 4 + type Tool struct { 5 + Type string `json:"type"` 6 + Function FunctionDef `json:"function"` 7 + } 8 + 9 + // FunctionDef defines a function that can be called by the LLM 10 + type FunctionDef struct { 11 + Name string `json:"name"` 12 + Description string `json:"description"` 13 + Parameters Parameters `json:"parameters"` 14 + } 15 + 16 + // Parameters defines the parameters schema for a function 17 + type Parameters struct { 18 + Type string `json:"type"` 19 + Properties map[string]Property `json:"properties"` 20 + Required []string `json:"required"` 21 + } 22 + 23 + // Property defines a single parameter property 24 + type Property struct { 25 + Type string `json:"type"` 26 + Description string `json:"description"` 27 + Items *Items `json:"items,omitempty"` 28 + } 29 + 30 + // Items defines the schema for array items 31 + type Items struct { 32 + Type string `json:"type"` 33 + } 34 + 35 + // ChatRequest represents a request to the chat completions API 36 + type ChatRequest struct { 37 + Model string `json:"model"` 38 + Messages []Message `json:"messages"` 39 + Tools []Tool `json:"tools,omitempty"` 40 + ToolChoice string `json:"tool_choice,omitempty"` 41 + } 42 + 43 + // Message represents a chat message 44 + type Message struct { 45 + Role string `json:"role"` 46 + Content string `json:"content"` 47 + ToolCalls []ToolCall `json:"tool_calls,omitempty"` 48 + ToolCallID string `json:"tool_call_id,omitempty"` 49 + } 50 + 51 + // ToolCall represents a tool invocation by the LLM 52 + type ToolCall struct { 53 + ID string `json:"id"` 54 + Type string `json:"type"` 55 + Function FunctionCall `json:"function"` 56 + } 57 + 58 + // FunctionCall represents the function being called 59 + type FunctionCall struct { 60 + Name string `json:"name"` 61 + Arguments string `json:"arguments"` 62 + } 63 + 64 + // ChatResponse represents a response from the chat completions API 65 + type ChatResponse struct { 66 + Choices []Choice `json:"choices"` 67 + Error *APIError `json:"error,omitempty"` 68 + } 69 + 70 + // Choice represents a response choice 71 + type Choice struct { 72 + Message Message `json:"message"` 73 + FinishReason string `json:"finish_reason"` 74 + } 75 + 76 + // APIError represents an API error response 77 + type APIError struct { 78 + Message string `json:"message"` 79 + } 80 + 81 + // SummarizeRequest represents a request to the summarize endpoint 82 + type SummarizeRequest struct { 83 + RepoURL string `json:"repo_url,omitempty"` 84 + RepoPath string `json:"repo_path,omitempty"` 85 + Base string `json:"base"` 86 + Head string `json:"head"` 87 + Style string `json:"style,omitempty"` // "detailed" (default), "short", "bluesky" 88 + } 89 + 90 + // SummarizeResponse represents a response from the summarize endpoint 91 + type SummarizeResponse struct { 92 + Summary string `json:"summary"` 93 + Error string `json:"error,omitempty"` 94 + }