+46
.tangled/workflows/release.yml
+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
+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
+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
+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
+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
+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
+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
git-summarizer
This is a binary file and will not be displayed.
+28
go.mod
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}