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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/openai/openai-go/v3 v3.50.0
modernc.org/sqlite v1.54.0
)

Expand All @@ -16,6 +15,7 @@ require (
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/openai/openai-go/v3 v3.50.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
Expand Down
478 changes: 69 additions & 409 deletions internal/server/ai.go

Large diffs are not rendered by default.

530 changes: 239 additions & 291 deletions internal/server/ai_test.go

Large diffs are not rendered by default.

81 changes: 4 additions & 77 deletions internal/server/coverage_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,65 +221,6 @@ func TestRSAKeyFromJWKRejectsMalformedComponents(t *testing.T) {
}
}

func TestChatMapsTransportReadAndPayloadFailures(t *testing.T) {
jsonResponse := func(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
}
}
tests := []struct {
name string
maxTokens int64
wantSent int64
rt roundTripperFunc
}{
{name: "transport", maxTokens: 10, wantSent: 10, rt: func(*http.Request) (*http.Response, error) { return nil, errors.New("offline") }},
{name: "read", maxTokens: 10, wantSent: 10, rt: func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: coverageReadCloser{readErr: errors.New("read failed")},
}, nil
}},
{name: "status", maxTokens: 10, wantSent: 10, rt: func(*http.Request) (*http.Response, error) {
return jsonResponse(http.StatusTeapot, `{"error":{"message":"no"}}`), nil
}},
{name: "invalid json", maxTokens: 10, wantSent: 10, rt: func(*http.Request) (*http.Response, error) {
return jsonResponse(http.StatusOK, "{"), nil
}},
// An unstated budget is never sent as one: the floor applies instead,
// and the recorded request is what says so — max_tokens:0 is a set
// field the upstream would reject, not an omitted one.
{name: "no choices", wantSent: aiDefaultMaxTokens, rt: func(*http.Request) (*http.Response, error) {
return jsonResponse(http.StatusOK, `{"choices":[]}`), nil
}},
{name: "truncated reply", maxTokens: 10, wantSent: 10, rt: func(*http.Request) (*http.Response, error) {
return jsonResponse(http.StatusOK, `{"choices":[{"index":0,"message":{"role":"assistant","content":"{"},"finish_reason":"length"}]}`), nil
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var sent []byte
recorder := roundTripperFunc(func(r *http.Request) (*http.Response, error) {
if r.Body != nil {
sent, _ = io.ReadAll(r.Body)
}
return tt.rt(r)
})
s := &server{aiClient: &http.Client{Transport: recorder}}
call := chatCall{msgs: []chatMessage{{Role: "user", Content: "hi"}}, maxTokens: tt.maxTokens}
if _, err := s.chat("u", aiConfig{baseURL: "https://ai.invalid", model: "m"}, call); err == nil {
t.Fatal("chat returned nil error")
}
if got := decodeAIRequest(t, sent).budget(); got != tt.wantSent {
t.Fatalf("output budget = %d, want %d", got, tt.wantSent)
}
})
}
}

func TestStoredAIConfigAndSettingsHandlersMapClosedStore(t *testing.T) {
st := newTestStore(t)
if err := st.Close(); err != nil {
Expand All @@ -292,8 +233,8 @@ func TestStoredAIConfigAndSettingsHandlersMapClosedStore(t *testing.T) {
if _, err := s.storedAIConfig("u"); err == nil {
t.Fatal("storedAIConfig accepted a closed store")
}
if _, err := s.chatCompletion("u", nil, 10, false); err == nil {
t.Fatal("chatCompletion accepted a closed store")
if _, err := s.runSkill(context.Background(), "u", skillScopeReadOnly, "adr-split", "in", 1, aiStoriesMaxTokens); err == nil {
t.Fatal("runSkill accepted a closed store")
}

for _, handler := range []struct {
Expand Down Expand Up @@ -414,14 +355,6 @@ func TestJWKSFetchRejectsTransportStatusBodyAndDocuments(t *testing.T) {
}
}

func TestDecodeJSONObjectRejectsNonObjectsAndMalformedObjects(t *testing.T) {
for _, body := range []string{`[]`, `{bad}`} {
if _, err := decodeJSONObject(body); err == nil {
t.Fatalf("decodeJSONObject(%q) returned nil error", body)
}
}
}

func TestClosedStoreHandlersReturnServerErrorsWithoutEgress(t *testing.T) {
st := newTestStore(t)
if err := st.Close(); err != nil {
Expand Down Expand Up @@ -477,14 +410,8 @@ func TestForgeDrainReportsReadAndCloseFailures(t *testing.T) {
}

func TestAIValueCoercionAndTextBoundsCoverDefensiveBranches(t *testing.T) {
if got, err := coerceDraft(`{"title":""}`); err != nil || got.Title != "" {
t.Fatalf("coerceDraft empty title = %+v, %v", got, err)
}
if _, err := coerceDrafts(`{"stories":"bad"}`, 2); err == nil {
t.Fatal("coerceDrafts accepted a non-array")
}
if got, err := coerceDrafts(`{"stories":[]}`, 2); err != nil || len(got) != 0 {
t.Fatalf("coerceDrafts empty = %+v, %v", got, err)
if got := coerceDraftMap(map[string]any{"title": ""}); got.Title != "" {
t.Fatalf("coerceDraftMap empty title = %+v", got)
}
if got := truncateImportText("hello", 0); got != "" {
t.Fatalf("truncate max zero = %q", got)
Expand Down
12 changes: 3 additions & 9 deletions internal/server/coverage_forge_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@

}

func TestFetchIssueAndCommentsRejectInvalidForgePayloads(t *testing.T) {

Check failure on line 183 in internal/server/coverage_forge_paths_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_kb&issues=AaAJiRIFTV2yRQvmEskk&open=AaAJiRIFTV2yRQvmEskk&pullRequest=75
refs := []forgeRef{
{Kind: "gitlab", Project: "group/project", Issue: 1, Source: store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: "https://gitlab.example"}},
{Kind: "github", Project: "owner/repo", Issue: 1, Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.com"}},
Expand Down Expand Up @@ -381,15 +381,9 @@
if _, err := transport.DialContext(context.Background(), "tcp", "invalid-address"); err == nil {
t.Fatal("guarded transport accepted invalid dial address")
}
if _, err := coerceDraft("{"); err == nil {
t.Fatal("coerceDraft accepted malformed JSON")
}
if _, err := coerceDrafts("{", 1); err == nil {
t.Fatal("coerceDrafts accepted malformed JSON")
}
draft, err := coerceDraft(`{"title":"x","prio":"3","due":"2026-01-01"}`)
if err != nil || draft.Prio != 3 || draft.Due != "2026-01-01" {
t.Fatalf("draft = %+v, err=%v", draft, err)
draft := coerceDraftMap(map[string]any{"title": "x", "prio": "3", "due": "2026-01-01"})
if draft.Prio != 3 || draft.Due != "2026-01-01" {
t.Fatalf("draft = %+v", draft)
}
if got := forgeIssueADR(forgeIssue{Title: "x", Comments: []string{"one"}}); !strings.Contains(got, "- one") {
t.Fatalf("ADR = %q", got)
Expand Down
96 changes: 71 additions & 25 deletions internal/server/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import (
"net/http"
"net/url"
"os"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"

"github.com/RandomCodeSpace/rig"

"github.com/RandomCodeSpace/kb/internal/store"
)

Expand Down Expand Up @@ -319,10 +322,8 @@ func parseGitLabRef(source store.ForgeSource, path string) (forgeRef, error) {
return ref, nil
}
}
for _, part := range parts {
if part == "-" {
return forgeRef{}, errors.New("invalid forge reference")
}
if slices.Contains(parts, "-") {
return forgeRef{}, errors.New("invalid forge reference")
}
return forgeRef{Source: source, Kind: source.Kind, Project: strings.Join(parts, "/")}, nil
}
Expand Down Expand Up @@ -380,10 +381,8 @@ func forgePathParts(path string) ([]string, error) {
return nil, errors.New("invalid forge reference")
}
parts := strings.Split(path, "/")
for _, part := range parts {
if part == "" {
return nil, errors.New("invalid forge reference")
}
if slices.Contains(parts, "") {
return nil, errors.New("invalid forge reference")
}
return parts, nil
}
Expand Down Expand Up @@ -911,6 +910,24 @@ func forgeAPIBase(kind, baseURL string) (string, error) {
return u.String(), nil
}

// importTransformSkillName is the skill the import preview runs. The endpoint
// keeps its own request and response shape; only the way the drafts are
// produced is shared with the other skill callers.
const importTransformSkillName = "import-transform"

// importPartialTransformNote tells the caller the draft list is short because
// the run ran out of room, not because the issues were judged noise.
const importPartialTransformNote = "the assistant stopped early — some issues produced no draft"

// appendImportNote joins a second note onto whatever fetchIssues already said,
// so a rate-limited fetch and a truncated transform can both be reported.
func appendImportNote(note, extra string) string {
if note == "" {
return extra
}
return note + "; " + extra
}

// handleImportPreview transforms a bounded, configured forge selection once;
// it never writes cards or provenance, which remain an explicit later commit.
func (s *server) handleImportPreview(w http.ResponseWriter, r *http.Request, user string) {
Expand Down Expand Up @@ -978,29 +995,43 @@ func (s *server) handleImportPreview(w http.ResponseWriter, r *http.Request, use
writeJSON(w, response)
return
}
content, err := s.chatCompletion(user, []chatMessage{
{Role: "system", Content: importSystemPrompt},
{Role: "user", Content: "Transform these numbered forge issues into kanban-card proposals:\n\n" + packed},
}, aiImportMaxTokens, true)
// Forge issues are third-party text — anyone who can comment on an issue
// writes part of this prompt — so the run is read-only: no board write, no
// outbound fetch. The card cap is stated as maxImportIssues rather than left
// to the default, because a pack may carry that many sources. The closing
// commentary is dropped; this endpoint's response shape is fixed.
run, err := s.runSkillForRequest(w, r, user, skillScopeReadOnly, importTransformSkillName, "Transform these numbered forge issues into kanban-card proposals:\n\n"+packed, maxImportIssues, aiImportMaxTokens)
if err != nil {
writeAIError(w, user, "import preview", err)
return
}
drafts, err := coerceDrafts(content, maxImportIssues)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
// A run cut short at a budget still returns the cards it did propose, and
// this endpoint drops the commentary that would have said so — so the note
// says it instead. Without it a truncated import is a 200 that reads as a
// complete one, and the issues that never became drafts look like issues
// the model deliberately skipped.
if run.Partial {
response.Note = appendImportNote(response.Note, importPartialTransformNote)
}
response.Drafts = buildImportPreviewDrafts(ref, drafts, issues, duplicates)
response.Drafts = buildImportPreviewDrafts(ref, run.Cards, issues, duplicates)
writeJSON(w, response)
}

// buildImportPreviewDrafts attaches forge provenance to the drafts the run
// proposed. One issue yields at most one linked draft: the model is told never
// to split an issue in two, and nothing enforces that, so a repeated source
// number would otherwise hand two drafts the same link, external key and
// duplicate pointer — and accepting both would put two board cards on one
// external key. The repeat keeps its card and loses only the provenance it
// cannot own.
func buildImportPreviewDrafts(ref forgeRef, drafts []storyDraft, issues []forgeIssue, duplicates []*importDuplicate) []importPreviewDraft {
previews := make([]importPreviewDraft, 0, len(drafts))
claimed := make(map[int]bool, len(drafts))
for _, draft := range drafts {
preview := importPreviewDraft{storyDraft: draft}
preview.Tags = stripModelLinkTags(preview.Tags)
if draft.Source > 0 && draft.Source <= len(issues) {
if draft.Source > 0 && draft.Source <= len(issues) && !claimed[draft.Source] {
claimed[draft.Source] = true
issue := issues[draft.Source-1]
link, externalKey := importIssueProvenance(ref, issue)
preview.Tags = append(preview.Tags, linkTagPrefix+link)
Expand Down Expand Up @@ -1325,11 +1356,26 @@ func (s *server) importDriftBaseline(user, externalKey string, current store.Imp
return current, false, nil
}

// importDriftSummaryPrompt is the whole instruction the drift summary gets.
// The run carries no tools: it compares two pieces of text the caller already
// holds, so a tool would only be another way for third-party issue text to
// reach the board.
const importDriftSummaryPrompt = "Summarize an imported issue change using only the supplied titles and excerpts."

// importDriftSummary is best-effort prose about what changed upstream. Every
// failure — no configuration, a bad endpoint, an upstream that never answers —
// degrades to no summary, because a drift comparison the caller asked for is
// valid without one. One run is one round trip: the loop is capped at a single
// iteration, and a toolless request cannot ask for a second.
func (s *server) importDriftSummary(user string, baseline, current store.ImportBaseline) string {
cfg, err := s.storedAIConfig(user)
if err != nil || strings.TrimSpace(cfg.baseURL) == "" {
return ""
}
client, err := s.rigClient(cfg)
if err != nil {
return ""
}
prompt := fmt.Sprintf(
"Summarize the material change in plain text. Do not invent details.\n\nBaseline title:\n%s\nBaseline excerpt:\n%s\n\nCurrent title:\n%s\nCurrent excerpt:\n%s",
truncateImportText(baseline.Title, maxImportCommentBytes),
Expand All @@ -1338,17 +1384,17 @@ func (s *server) importDriftSummary(user string, baseline, current store.ImportB
current.Excerpt,
)
prompt = truncateImportText(prompt, maxImportPackBytes)
msg, err := s.chat(user, cfg, chatCall{
msgs: []chatMessage{
{Role: "system", Content: "Summarize an imported issue change using only the supplied titles and excerpts."},
{Role: "user", Content: prompt},
},
maxTokens: aiDriftMaxTokens,
res, err := client.Run(context.Background(), rig.RunRequest{
Model: cfg.model,
System: importDriftSummaryPrompt,
Prompt: prompt,
MaxTokens: skillBudget(aiDriftMaxTokens),
MaxIterations: 1,
})
if err != nil {
return ""
}
return truncateImportText(strings.TrimSpace(msg.Content), maxImportCommentBytes)
return truncateImportText(strings.TrimSpace(res.Text), maxImportCommentBytes)
}

func (s *server) importDuplicates(scope string, issues []forgeIssue) ([]*importDuplicate, error) {
Expand Down
Loading