Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions internal/adapters/llm/openai_standard/adapter_commit.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package openai_standard

import (
"encoding/json"
"fmt"
"strings"

Expand Down Expand Up @@ -45,19 +46,70 @@ func extractCommitInfo(chunk domain.DiffChunk) (string, bool) {
return strings.TrimSuffix(commitType, "!"), breaking
}

// buildChunkAnnotationJSON marshals the structured annotation fields of a
// DiffChunk into the JSON strings expected by MessageParams.
//
// annotatedJSON is empty when AnnotatedEntries is empty (so the template falls
// back to the legacy AnnotatedDiff / raw Diff). callGraphJSON is "[]" when
// there are no call edges (still rendered as an empty array alongside the
// annotated_diff block). cfgJSON is empty when CFGBefore/CFGAfter are nil (cfg
// not computed); otherwise it is a CFGSummary JSON object.
func buildChunkAnnotationJSON(chunk *domain.DiffChunk) (annotatedJSON, callGraphJSON, cfgJSON string) {
if len(chunk.AnnotatedEntries) > 0 {
data, err := json.Marshal(chunk.AnnotatedEntries)
if err == nil {
annotatedJSON = string(data)
}
}
if annotatedJSON == "" {
return "", "", ""
}

// Call graph: marshal to "[]" when empty so the template renders the block.
cgData, err := json.Marshal(chunk.CallGraph)
if err == nil {
callGraphJSON = string(cgData)
} else {
callGraphJSON = "[]"
}

// CFG summary: only when both before/after are present (computed).
if chunk.CFGBefore != nil && chunk.CFGAfter != nil {
summary := domain.CFGSummary{
Conditionals: domain.CFGEntry{Before: chunk.CFGBefore.Branch, After: chunk.CFGAfter.Branch},
Loops: domain.CFGEntry{Before: chunk.CFGBefore.Loop, After: chunk.CFGAfter.Loop},
Returns: domain.CFGEntry{Before: chunk.CFGBefore.Return, After: chunk.CFGAfter.Return},
Errors: domain.CFGEntry{Before: chunk.CFGBefore.Error, After: chunk.CFGAfter.Error},
}
if data, err := json.Marshal(summary); err == nil {
cfgJSON = string(data)
}
}

return annotatedJSON, callGraphJSON, cfgJSON
}

// GenerateChunkMessage generates a conventional commit message for a single diff chunk.
func (a *OpenAIStandardAdapter) GenerateChunkMessage(chunk domain.DiffChunk) (string, error) {
commitType, breaking := extractCommitInfo(chunk)

annotatedJSON, callGraphJSON, cfgJSON := buildChunkAnnotationJSON(&chunk)
annotatedDiff := chunk.AnnotatedDiff
rawDiff := chunk.Diff
// When structured entries are present, drop the legacy emoji AnnotatedDiff
// so the prompt uses the JSON path exclusively (spec: no emoji in prompt
// input, no raw diff when annotated_diff is non-empty).
if annotatedJSON != "" {
annotatedDiff = ""
rawDiff = ""
}

var prompt string
var err error
if a.retryContext != "" {
prompt, err = prompts.Render(prompts.GetCommitMessage(), prompts.BuildMessageParamsWithRetry(chunk.Files, annotatedDiff, rawDiff, a.retryContext, a.context, commitType, chunk.Scope, breaking, a.why))
prompt, err = prompts.Render(prompts.GetCommitMessage(), prompts.BuildMessageParamsWithRetry(chunk.Files, annotatedJSON, callGraphJSON, cfgJSON, annotatedDiff, rawDiff, a.retryContext, a.context, commitType, chunk.Scope, breaking, a.why))
} else {
prompt, err = prompts.Render(prompts.GetCommitMessage(), prompts.BuildMessageParams(chunk.Files, annotatedDiff, rawDiff, a.context, commitType, chunk.Scope, breaking, a.why))
prompt, err = prompts.Render(prompts.GetCommitMessage(), prompts.BuildMessageParams(chunk.Files, annotatedJSON, callGraphJSON, cfgJSON, annotatedDiff, rawDiff, a.context, commitType, chunk.Scope, breaking, a.why))
}
if err != nil {
return "", fmt.Errorf("render commit prompt: %w", err)
Expand Down
190 changes: 190 additions & 0 deletions internal/adapters/llm/openai_standard/adapter_commit_json_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package openai_standard

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/blak0p/git-courer/internal/core/domain"
)

// TestGenerateChunkMessage_AnnotatedEntriesMarshalledToJSON verifies that when
// a chunk carries AnnotatedEntries, GenerateChunkMessage marshals them to JSON
// and the rendered prompt contains the annotated_diff JSON block (not the
// legacy emoji AnnotatedDiff and not the raw diff).
func TestGenerateChunkMessage_AnnotatedEntriesMarshalledToJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
userMsg := req.Messages[1].Content
if !strings.Contains(userMsg, "annotated_diff") {
t.Errorf("prompt should contain annotated_diff JSON block; got:\n%s", userMsg)
}
if !strings.Contains(userMsg, `"symbol":"Handler"`) {
t.Errorf("prompt should contain the marshalled Handler entry; got:\n%s", userMsg)
}
// When AnnotatedEntries is non-empty, the raw diff must NOT be sent.
if strings.Contains(userMsg, "raw diff content") {
t.Errorf("prompt should NOT contain raw diff when AnnotatedEntries is present; got:\n%s", userMsg)
}
w.Header().Set("Content-Type", "application/json")
w.Write(chatCompletionResponse(mockJSONResponse(t, CommitMessageJSON{Description: "add handler"})))
}))
defer server.Close()

adapter := newTestAdapter(server)
chunk := domain.DiffChunk{
Files: []string{"internal/api/handler.go"},
Diff: "raw diff content",
AnnotatedEntries: []domain.AnnotatedEntry{
{File: "internal/api/handler.go", Symbol: "Handler", Type: "NEW_FUNC", Line: 10, After: "+ func Handler() {}"},
},
CallGraph: []domain.CallGraphEntry{
{From: "internal/api/handler.go", To: "internal/api/util.go", Symbol: "Helper"},
},
CFGBefore: &domain.CFGCount{Branch: 0, Loop: 0, Return: 0, Error: 0},
CFGAfter: &domain.CFGCount{Branch: 1, Loop: 0, Return: 0, Error: 0},
CommitType: "feat",
}
if _, err := adapter.GenerateChunkMessage(chunk); err != nil {
t.Fatalf("GenerateChunkMessage failed: %v", err)
}
}

// TestGenerateChunkMessage_CallGraphJSONInPrompt verifies that the call_graph
// JSON block is rendered in the prompt when the chunk has CallGraph entries.
func TestGenerateChunkMessage_CallGraphJSONInPrompt(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
_ = json.NewDecoder(r.Body).Decode(&req)
userMsg := req.Messages[1].Content
if !strings.Contains(userMsg, "call_graph") {
t.Errorf("prompt should contain call_graph block; got:\n%s", userMsg)
}
if !strings.Contains(userMsg, `"symbol":"Helper"`) {
t.Errorf("prompt should contain Helper call edge; got:\n%s", userMsg)
}
w.Header().Set("Content-Type", "application/json")
w.Write(chatCompletionResponse(mockJSONResponse(t, CommitMessageJSON{Description: "wire helper"})))
}))
defer server.Close()

adapter := newTestAdapter(server)
chunk := domain.DiffChunk{
Files: []string{"a.go"},
Diff: "diff",
AnnotatedEntries: []domain.AnnotatedEntry{
{File: "a.go", Symbol: "F", Type: "NEW_FUNC", Line: 1},
},
CallGraph: []domain.CallGraphEntry{
{From: "a.go", To: "b.go", Symbol: "Helper"},
},
CommitType: "feat",
}
if _, err := adapter.GenerateChunkMessage(chunk); err != nil {
t.Fatalf("GenerateChunkMessage failed: %v", err)
}
}

// TestGenerateChunkMessage_CFGSummaryJSONInPrompt verifies the cfg block is
// rendered when CFGBefore/CFGAfter are present, and that conditionals carry
// before/after counts.
func TestGenerateChunkMessage_CFGSummaryJSONInPrompt(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
_ = json.NewDecoder(r.Body).Decode(&req)
userMsg := req.Messages[1].Content
if !strings.Contains(userMsg, "cfg") {
t.Errorf("prompt should contain cfg block; got:\n%s", userMsg)
}
if !strings.Contains(userMsg, `"conditionals"`) {
t.Errorf("prompt should contain conditionals entry; got:\n%s", userMsg)
}
w.Header().Set("Content-Type", "application/json")
w.Write(chatCompletionResponse(mockJSONResponse(t, CommitMessageJSON{Description: "add branch"})))
}))
defer server.Close()

adapter := newTestAdapter(server)
chunk := domain.DiffChunk{
Files: []string{"a.go"},
Diff: "diff",
AnnotatedEntries: []domain.AnnotatedEntry{
{File: "a.go", Symbol: "F", Type: "MOD_BODY_LOGIC", Line: 1},
},
CFGBefore: &domain.CFGCount{Branch: 0, Loop: 1, Return: 2, Error: 0},
CFGAfter: &domain.CFGCount{Branch: 1, Loop: 1, Return: 2, Error: 0},
CommitType: "fix",
}
if _, err := adapter.GenerateChunkMessage(chunk); err != nil {
t.Fatalf("GenerateChunkMessage failed: %v", err)
}
}

// TestGenerateChunkMessage_CFGNullWhenNotComputed verifies the cfg block is
// omitted (null) when CFGBefore/CFGAfter are nil (not computed).
func TestGenerateChunkMessage_CFGNullWhenNotComputed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
_ = json.NewDecoder(r.Body).Decode(&req)
userMsg := req.Messages[1].Content
// annotated_diff must still be present (entries are non-empty).
if !strings.Contains(userMsg, "annotated_diff") {
t.Errorf("prompt should contain annotated_diff; got:\n%s", userMsg)
}
// cfg must NOT be present when CFGBefore/CFGAfter are nil.
if strings.Contains(userMsg, "cfg") {
t.Errorf("prompt should NOT contain cfg block when CFG not computed; got:\n%s", userMsg)
}
w.Header().Set("Content-Type", "application/json")
w.Write(chatCompletionResponse(mockJSONResponse(t, CommitMessageJSON{Description: "add"})))
}))
defer server.Close()

adapter := newTestAdapter(server)
chunk := domain.DiffChunk{
Files: []string{"a.go"},
Diff: "diff",
AnnotatedEntries: []domain.AnnotatedEntry{
{File: "a.go", Symbol: "F", Type: "NEW_FUNC", Line: 1},
},
CommitType: "feat",
}
if _, err := adapter.GenerateChunkMessage(chunk); err != nil {
t.Fatalf("GenerateChunkMessage failed: %v", err)
}
}

// TestGenerateChunkMessage_FallsBackToRawDiffWhenNoEntries verifies the
// regression: when AnnotatedEntries is empty, the raw diff is used (no
// annotated_diff block). This preserves the existing fallback contract.
func TestGenerateChunkMessage_FallsBackToRawDiffWhenNoEntries(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
_ = json.NewDecoder(r.Body).Decode(&req)
userMsg := req.Messages[1].Content
if strings.Contains(userMsg, "annotated_diff") {
t.Errorf("prompt should NOT contain annotated_diff when entries empty; got:\n%s", userMsg)
}
if !strings.Contains(userMsg, "raw diff here") {
t.Errorf("prompt should contain raw diff fallback; got:\n%s", userMsg)
}
w.Header().Set("Content-Type", "application/json")
w.Write(chatCompletionResponse(mockJSONResponse(t, CommitMessageJSON{Description: "add"})))
}))
defer server.Close()

adapter := newTestAdapter(server)
chunk := domain.DiffChunk{
Files: []string{"a.go"},
Diff: "raw diff here",
CommitType: "feat",
}
if _, err := adapter.GenerateChunkMessage(chunk); err != nil {
t.Fatalf("GenerateChunkMessage failed: %v", err)
}
}
13 changes: 12 additions & 1 deletion internal/adapters/llm/openai_standard/adapter_release.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,18 @@ func (a *OpenAIStandardAdapter) RegenerateMessage(previousMessages []string, fee
// regenerateChunk is the per-chunk logic extracted for reuse in serial and parallel paths.
func (a *OpenAIStandardAdapter) regenerateChunk(chunk domain.DiffChunk, feedback string) (string, error) {
commitType, breaking := extractCommitInfo(chunk)
prompt, err := prompts.Render(prompts.GetCommitMessage(), prompts.BuildMessageParamsWithRetry(chunk.Files, chunk.AnnotatedDiff, chunk.Diff, feedback, a.context, commitType, chunk.Scope, breaking, a.why))

annotatedJSON, callGraphJSON, cfgJSON := buildChunkAnnotationJSON(&chunk)
annotatedDiff := chunk.AnnotatedDiff
rawDiff := chunk.Diff
// When structured entries are present, drop the legacy emoji AnnotatedDiff
// and raw diff so the prompt uses the JSON path exclusively.
if annotatedJSON != "" {
annotatedDiff = ""
rawDiff = ""
}

prompt, err := prompts.Render(prompts.GetCommitMessage(), prompts.BuildMessageParamsWithRetry(chunk.Files, annotatedJSON, callGraphJSON, cfgJSON, annotatedDiff, rawDiff, feedback, a.context, commitType, chunk.Scope, breaking, a.why))
if err != nil {
return "", fmt.Errorf("render regenerate prompt: %w", err)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package openai_standard

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/blak0p/git-courer/internal/core/domain"
)

// TestRegenerateChunk_AnnotatedEntriesMarshalledToJSON verifies that
// regenerateChunk (used by RegenerateMessage) marshals AnnotatedEntries to
// JSON and renders the annotated_diff block in the regenerate prompt, with
// the rejected message context.
func TestRegenerateChunk_AnnotatedEntriesMarshalledToJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
userMsg := req.Messages[1].Content
if !strings.Contains(userMsg, "annotated_diff") {
t.Errorf("regenerate prompt should contain annotated_diff JSON block; got:\n%s", userMsg)
}
if !strings.Contains(userMsg, `"symbol":"Handler"`) {
t.Errorf("regenerate prompt should contain marshalled Handler entry; got:\n%s", userMsg)
}
// Rejected message context must be present.
if !strings.Contains(userMsg, "Rejected Message") {
t.Errorf("regenerate prompt should contain Rejected Message block; got:\n%s", userMsg)
}
if !strings.Contains(userMsg, "previous bad message") {
t.Errorf("regenerate prompt should contain the rejected message text; got:\n%s", userMsg)
}
// Raw diff must NOT be sent when AnnotatedEntries is present.
if strings.Contains(userMsg, "raw diff content") {
t.Errorf("regenerate prompt should NOT contain raw diff when entries present; got:\n%s", userMsg)
}
w.Header().Set("Content-Type", "application/json")
w.Write(chatCompletionResponse(mockJSONResponse(t, CommitMessageJSON{Description: "add handler"})))
}))
defer server.Close()

adapter := newTestAdapter(server)
chunk := domain.DiffChunk{
Files: []string{"internal/api/handler.go"},
Diff: "raw diff content",
AnnotatedEntries: []domain.AnnotatedEntry{
{File: "internal/api/handler.go", Symbol: "Handler", Type: "NEW_FUNC", Line: 10, After: "+ func Handler() {}"},
},
CommitType: "feat",
}
if _, err := adapter.regenerateChunk(chunk, "previous bad message"); err != nil {
t.Fatalf("regenerateChunk failed: %v", err)
}
}

// TestRegenerateChunk_FallsBackToRawDiffWhenNoEntries verifies the regenerate
// path falls back to the raw diff when AnnotatedEntries is empty.
func TestRegenerateChunk_FallsBackToRawDiffWhenNoEntries(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ChatRequest
_ = json.NewDecoder(r.Body).Decode(&req)
userMsg := req.Messages[1].Content
if strings.Contains(userMsg, "annotated_diff") {
t.Errorf("regenerate prompt should NOT contain annotated_diff when entries empty; got:\n%s", userMsg)
}
if !strings.Contains(userMsg, "raw diff here") {
t.Errorf("regenerate prompt should contain raw diff fallback; got:\n%s", userMsg)
}
w.Header().Set("Content-Type", "application/json")
w.Write(chatCompletionResponse(mockJSONResponse(t, CommitMessageJSON{Description: "add"})))
}))
defer server.Close()

adapter := newTestAdapter(server)
chunk := domain.DiffChunk{
Files: []string{"a.go"},
Diff: "raw diff here",
CommitType: "feat",
}
if _, err := adapter.regenerateChunk(chunk, "feedback"); err != nil {
t.Fatalf("regenerateChunk failed: %v", err)
}
}
Loading
Loading