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
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ require (
charm.land/lipgloss/v2 v2.0.6
github.com/RandomCodeSpace/rig v0.2.0
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.8
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260816001655-68d539dca504
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
Expand All @@ -18,11 +20,10 @@ require (

require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
Expand Down
7 changes: 7 additions & 0 deletions internal/ai/ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ func endpoint(base string) (string, error) {
return u.String(), nil
}

// ValidateBaseURL applies the same endpoint validation used by model runs
// without constructing a client or reading a credential.
func ValidateBaseURL(base string) error {
_, err := endpoint(base)
return err
}

func usesMaxCompletionTokens(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
if i := strings.LastIndex(name, "/"); i >= 0 {
Expand Down
48 changes: 31 additions & 17 deletions internal/server/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -1565,7 +1565,7 @@ func (s *server) handleTestIntegration(w http.ResponseWriter, r *http.Request, u
return
}

request, err := newForgeTestRequest(r.Context(), kind, target)
request, err := newForgeTestRequest(r.Context(), kind, target, "")
if err != nil {
writeJSON(w, forgeTestResponse{Error: err.Error()})
return
Expand Down Expand Up @@ -1614,19 +1614,28 @@ func trimmedForgeProbeValue(value *string) string {
return strings.TrimSpace(*value)
}

func newForgeTestRequest(ctx context.Context, kind string, target forgeTestTarget) (*http.Request, error) {
func newForgeTestRequest(ctx context.Context, kind string, target forgeTestTarget, project string) (*http.Request, error) {
apiBase, err := forgeAPIBase(kind, target.baseURL)
if err != nil {
return nil, err
}
project = strings.TrimSpace(project)
endpoint := apiBase
switch kind {
case "gitlab":
endpoint += "/version"
case "github":
endpoint += "/user"
default:
return nil, errors.New("invalid forge kind")
if project != "" {
projectPath, err := forgeProjectPath(forgeRef{Kind: kind, Project: project})
if err != nil {
return nil, err
}
endpoint += projectPath
} else {
switch kind {
case "gitlab":
endpoint += "/version"
case "github":
endpoint += "/user"
default:
return nil, errors.New("invalid forge kind")
}
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
Expand All @@ -1649,21 +1658,26 @@ func setForgeTestHeaders(request *http.Request, kind, pat string) {
}

func (s *server) forgeConnectionOK(request *http.Request, user string) bool {
response, err := s.forgeClient.Do(request)
err := executeForgeTest(s.forgeClient, request)
if err != nil {
log.Printf("forge: connection test for %s failed: %v", user, err)
return false
}
drainErr := drainForgeResponse(response)
if drainErr != nil {
log.Printf("forge: connection test for %s failed while closing response: %v", user, drainErr)
return false
return true
}

func executeForgeTest(client *http.Client, request *http.Request) error {
response, err := client.Do(request)
if err != nil {
return err
}
if err := drainForgeResponse(response); err != nil {
return fmt.Errorf("close response: %w", err)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
log.Printf("forge: connection test for %s failed: upstream status %d", user, response.StatusCode)
return false
return fmt.Errorf("upstream status %d", response.StatusCode)
}
return true
return nil
}

func drainForgeResponse(response *http.Response) error {
Expand Down
77 changes: 77 additions & 0 deletions internal/server/forge_probe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package server

import (
"context"
"errors"
"net/http"
"strings"

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

// ForgeProber tests stored or draft forge integration values without going
// through kb's HTTP API. It is shared by local frontends so they use the same guarded
// client, credential-origin rule, endpoint derivation, and response handling
// as the server handler.
type ForgeProber struct {
store *store.Store
client *http.Client
}

// ForgeProbeConfig is one connection test candidate. Saved selects whether
// blank endpoint credentials may fall back to the named stored integration;
// drafts set it false and are tested entirely from these unsaved values.
// Project is probe-only and is never persisted with the integration.
type ForgeProbeConfig struct {
Name string
Kind string
BaseURL string
Project string
Token string
Saved bool
}

// NewForgeProber constructs a direct-store forge connection prober.
func NewForgeProber(st *store.Store) *ForgeProber {
return &ForgeProber{store: st, client: newForgeClient()}
}

// Probe tests one unsaved candidate. Persisted rows may retain blank stored
// endpoint values; drafts never touch the store. A stored token is never sent
// to a supplied origin that differs from the saved one.
func (p *ForgeProber) Probe(ctx context.Context, user string, config ForgeProbeConfig) error {
if strings.TrimSpace(config.Name) == "" {
return errors.New("integration name is required")
}
kind := strings.TrimSpace(config.Kind)
storedBase, storedToken := "", ""
if config.Saved {
storedKind, baseURL, token, err := p.store.ForgePAT(user, config.Name)
if err != nil {
return errors.New("integration unavailable")
}
if kind == "" {
kind = storedKind
}
storedBase, storedToken = baseURL, token
}
probe := forgeTestProbe{}
if strings.TrimSpace(config.BaseURL) != "" {
probe.BaseURL = &config.BaseURL
}
if strings.TrimSpace(config.Token) != "" {
probe.PAT = &config.Token
}
target, err := resolveForgeTestTarget(storedBase, storedToken, probe)
if err != nil {
return err
}
request, err := newForgeTestRequest(ctx, kind, target, strings.TrimSpace(config.Project))
if err != nil {
return err
}
if err := executeForgeTest(p.client, request); err != nil {
return errors.New(connectionFailedMessage)
}
return nil
}
93 changes: 93 additions & 0 deletions internal/server/forge_prober_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package server

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestForgeProberUsesUnsavedValuesWithoutSaving(t *testing.T) {
st := newTestStore(t)
storedBase, storedPAT := "https://stored.example", "stored-token"
if _, err := st.SetForgeSource("alice", "primary", "gitlab", &storedBase, &storedPAT); err != nil {
t.Fatal(err)
}

const candidatePAT = "candidate-token"
var requests int
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.EscapedPath() != "/api/v4/projects/group%2Fproject" || r.Header.Get("PRIVATE-TOKEN") != candidatePAT {
t.Errorf("probe request = %s token=%q", r.URL.EscapedPath(), r.Header.Get("PRIVATE-TOKEN"))
}
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()

prober := &ForgeProber{store: st, client: upstream.Client()}
draft := ForgeProbeConfig{
Name: "unsaved", Kind: "gitlab", BaseURL: upstream.URL,
Project: "group/project", Token: candidatePAT,
}
if err := prober.Probe(context.Background(), "alice", draft); err != nil {
t.Fatalf("Probe: %v", err)
}
if requests != 1 {
t.Fatalf("probe requests = %d", requests)
}
if err := prober.Probe(context.Background(), "alice", ForgeProbeConfig{
Name: "primary", Kind: "gitlab", BaseURL: upstream.URL,
Project: "group/project", Token: candidatePAT, Saved: true,
}); err != nil {
t.Fatalf("saved candidate Probe: %v", err)
}
if requests != 2 {
t.Fatalf("saved candidate probe requests = %d", requests)
}
if kind, base, pat, err := st.ForgePAT("alice", "primary"); err != nil || kind != "gitlab" || base != storedBase || pat != storedPAT {
t.Fatalf("probe changed store = %q/%q/%q, %v", kind, base, pat, err)
}
if sources, err := st.ForgeSources("alice"); err != nil || len(sources) != 1 || sources[0].Name != "primary" {
t.Fatalf("probe changed source list = %+v, %v", sources, err)
}

if err := prober.Probe(context.Background(), "alice", ForgeProbeConfig{
Name: "primary", Kind: "gitlab", BaseURL: upstream.URL, Saved: true,
}); err == nil ||
err.Error() != "enter the token to test a different endpoint" || requests != 2 {
t.Fatalf("cross-origin stored token probe = %v requests=%d", err, requests)
}
if err := prober.Probe(context.Background(), "alice", ForgeProbeConfig{Name: "missing", Saved: true}); err == nil || err.Error() != "integration unavailable" {
t.Fatalf("missing integration probe = %v", err)
}
}

func TestForgeProberCollapsesNetworkFailuresAndHonorsCancellation(t *testing.T) {
st := newTestStore(t)
base := "https://forge.example"
if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil {
t.Fatal(err)
}
client := &http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
<-request.Context().Done()
return nil, request.Context().Err()
})}
prober := &ForgeProber{store: st, client: client}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := prober.Probe(ctx, "alice", ForgeProbeConfig{Name: "primary", Saved: true})
if err == nil || err.Error() != connectionFailedMessage || strings.Contains(err.Error(), base) {
t.Fatalf("cancelled probe error = %v", err)
}

prober.client = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("network exposed candidate-token")
})}
err = prober.Probe(context.Background(), "alice", ForgeProbeConfig{Name: "primary", Token: "candidate-token", Saved: true})
if err == nil || err.Error() != connectionFailedMessage || strings.Contains(err.Error(), "candidate-token") {
t.Fatalf("network probe error = %v", err)
}
}
2 changes: 1 addition & 1 deletion internal/server/forge_sonar_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ func TestIntegrationProbeReportsRequestBuildErrorWithoutEgress(t *testing.T) {

func TestNewForgeTestRequestRejectsNilContextBeforeEgress(t *testing.T) {
//lint:ignore SA1012 Deliberately exercise the request constructor's nil-context error.
request, err := newForgeTestRequest(nil, "gitlab", forgeTestTarget{baseURL: "https://gitlab.example.test"})
request, err := newForgeTestRequest(nil, "gitlab", forgeTestTarget{baseURL: "https://gitlab.example.test"}, "")
if request != nil || err == nil || err.Error() != "invalid forge base URL" {
t.Fatalf("nil-context request = (%v, %v), want nil request and construction error", request, err)
}
Expand Down
31 changes: 30 additions & 1 deletion internal/tui/board_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,39 @@
if m.boardView.showCancelled {
cancelled = "on"
}
footer := fitLine(state+" | j/k cards | h/l/tab columns | 1-4 jump | c cancelled:"+cancelled+" | q quit", width)
help := "j/k cards | h/l/tab columns | 1-4 jump | c cancelled:" + cancelled + " | q quit"
if m.settingsNew != nil {
footer := settingsBoardFooter(state, cancelled, width)
return strings.Join([]string{header, body, footer}, "\n"), hits
}
footer := fitLine(state+" | "+help, width)
return strings.Join([]string{header, body, footer}, "\n"), hits
}

func settingsBoardFooter(state, cancelled string, width int) string {
candidates := [][]string{
{"s settings", "j/k cards", "h/l/tab columns", "1-4 jump", "c cancelled:" + cancelled, "q quit"},

Check failure on line 272 in internal/tui/board_view.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "h/l/tab columns" 3 times.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_kb&issues=AaAQSIIam5o4Wyi5oF-f&open=AaAQSIIam5o4Wyi5oF-f&pullRequest=103

Check failure on line 272 in internal/tui/board_view.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "s settings" 5 times.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_kb&issues=AaAQSIIam5o4Wyi5oF-g&open=AaAQSIIam5o4Wyi5oF-g&pullRequest=103

Check failure on line 272 in internal/tui/board_view.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "j/k cards" 3 times.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_kb&issues=AaAQSIIam5o4Wyi5oF-e&open=AaAQSIIam5o4Wyi5oF-e&pullRequest=103

Check failure on line 272 in internal/tui/board_view.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "q quit" 10 times.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_kb&issues=AaAQSIIZm5o4Wyi5oF-d&open=AaAQSIIZm5o4Wyi5oF-d&pullRequest=103
{"s settings", "j/k cards", "h/l/tab columns", "c cancelled:" + cancelled, "q quit"},
{"s settings", "j/k cards", "h/l/tab columns", "q quit"},
{"s settings", "j/k", "h/l/tab", "q quit"},
{"s settings", "j/k h/l", "q quit"},
{"s", "j/k h/l", "q quit"},
{"s", "nav", "q quit"},
{"s", "q quit"},
{"q quit"},
}
minimumStateWidth := min(ansi.StringWidth(state), 5)
for _, candidate := range candidates {
help := strings.Join(candidate, " | ")
stateWidth := width - ansi.StringWidth(help) - 3
if stateWidth < minimumStateWidth {
continue
}
return fitLine(state, stateWidth) + " | " + help
}
return fitLine("q quit", width)
}

func (m Model) render() string {
content, _ := m.renderBoard()
return content
Expand Down
Loading
Loading