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
7 changes: 7 additions & 0 deletions config/ai_connectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ func RenderManagedAIConnectorsSection(connectors []uicfg.ConnectorRemote, sectio
builder.WriteString("provider_name = ")
builder.WriteString(strconv.Quote(connector.ProviderName))
builder.WriteString("\n")
role := strings.TrimSpace(connector.Role)
if role == "" {
role = "leader"
}
builder.WriteString("role = ")
builder.WriteString(strconv.Quote(role))
builder.WriteString("\n")
builder.WriteString("connector_name = ")
builder.WriteString(strconv.Quote(connector.ConnectorName))
builder.WriteString("\n")
Expand Down
11 changes: 6 additions & 5 deletions internal/appui/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import (
)

const (
cloudAPIURL = setuptpl.CloudAPIURL
geminiKeysURL = setuptpl.GeminiKeysURL
defaultGeminiModel = setuptpl.DefaultGeminiModel
setupTimeout = 5 * time.Minute
issuesURL = setuptpl.IssuesURL
cloudAPIURL = setuptpl.CloudAPIURL
geminiKeysURL = setuptpl.GeminiKeysURL
defaultGeminiModel = setuptpl.DefaultGeminiModel
defaultGeminiHelperModel = setuptpl.DefaultGeminiHelperModel
setupTimeout = 5 * time.Minute
issuesURL = setuptpl.IssuesURL
)

type setupResult = setuptpl.SetupResult
Expand Down
19 changes: 18 additions & 1 deletion internal/appui/setup_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,22 @@ func RunSetup(c *cli.Context) error {
return setupError(slog, fmt.Errorf("failed to create AI connector: %w", err))
}
fmt.Printf(" %s✅ Gemini connector created%s %s(model: %s)%s\n", clr(cGreen), clr(cReset), clr(cDim), defaultGeminiModel, clr(cReset))
fmt.Println()
slog.write("gemini connector created")

// Helper connector powers Adaptive Review out of the box, reusing the
// same free key. Best-effort: the leader connector above is already
// committed, so a failure here shouldn't fail the whole setup — the org
// just stays leader-only until they configure a helper connector later.
slog.write("creating gemini helper connector")
if err := createGeminiHelperConnector(result, geminiKey, selectedAPIURL); err != nil {
slog.write("warning: failed to create helper connector: %v", err)
fmt.Printf(" %s⚠ Could not create Helper connector%s %s(Adaptive Review will stay leader-only for now)%s\n", clr(cYellow), clr(cReset), clr(cDim), clr(cReset))
} else {
fmt.Printf(" %s✅ Helper connector created%s %s(model: %s)%s\n", clr(cGreen), clr(cReset), clr(cDim), defaultGeminiHelperModel, clr(cReset))
slog.write("gemini helper connector created")
}
fmt.Println()

if !skipLogin {
if err := writeConfig(result, selectedAPIURL); err != nil {
return setupError(slog, fmt.Errorf("failed to write config: %w", err))
Expand Down Expand Up @@ -413,6 +426,10 @@ func createGeminiConnector(result *setupResult, geminiKey string, apiURL string)
return setuptpl.CreateGeminiConnector(result, geminiKey, apiURL)
}

func createGeminiHelperConnector(result *setupResult, geminiKey string, apiURL string) error {
return setuptpl.CreateGeminiHelperConnector(result, geminiKey, apiURL)
}

func writeConfig(result *setupResult, apiURL string) error {
return setuptpl.WriteConfigWithOptions(result, setuptpl.WriteConfigOptions{APIURL: strings.TrimSpace(apiURL)})
}
Expand Down
26 changes: 26 additions & 0 deletions internal/appui/ui_connectors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ org_id = "42"
APIKey: "gkey",
SelectedModel: "gemini-2.5-flash",
DisplayOrder: 1,
Role: "leader",
},
{
ID: 8,
ProviderName: "gemini",
ConnectorName: "Gemini Flash-Lite (Helper)",
APIKey: "gkey2",
SelectedModel: "gemini-2.5-flash-lite",
DisplayOrder: 1,
Role: "helper",
},
{
ID: 9,
ProviderName: "gemini",
ConnectorName: "Legacy connector with no role",
APIKey: "gkey3",
SelectedModel: "gemini-2.5-flash",
DisplayOrder: 2,
Role: "",
},
}

Expand Down Expand Up @@ -56,6 +75,13 @@ org_id = "42"
if !strings.Contains(updated, `[[ai_connectors]]`) || !strings.Contains(updated, `provider_name = "gemini"`) {
t.Fatalf("connector data not written to managed section")
}

if strings.Count(updated, `role = "leader"`) != 2 {
t.Fatalf("expected two connectors (explicit leader + empty-role fallback) rendered with role=\"leader\", got: %s", updated)
}
if !strings.Contains(updated, `role = "helper"`) {
t.Fatalf("expected the helper connector to render role=\"helper\", got: %s", updated)
}
}

func TestPersistAuthTokensToConfigUpdatesExistingTokens(t *testing.T) {
Expand Down
60 changes: 44 additions & 16 deletions setup/connectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,17 @@ func redactConnectorErrorBody(body []byte, secrets ...string) string {
return msg
}

// ValidateGeminiKey checks the key against LiveReview's validate-key endpoint.
func ValidateGeminiKey(result *SetupResult, geminiKey string, apiURL string) (bool, string, error) {
func defaultAPIURL(apiURL string) string {
apiURL = strings.TrimSpace(apiURL)
if apiURL == "" {
apiURL = CloudAPIURL
return CloudAPIURL
}
return apiURL
}

// ValidateGeminiKey checks the key against LiveReview's validate-key endpoint.
func ValidateGeminiKey(result *SetupResult, geminiKey string, apiURL string) (bool, string, error) {
apiURL = defaultAPIURL(apiURL)

reqBody := ValidateKeyRequest{
Provider: "gemini",
Expand All @@ -56,29 +61,52 @@ func ValidateGeminiKey(result *SetupResult, geminiKey string, apiURL string) (bo
return valResp.Valid, valResp.Message, nil
}

// CreateGeminiConnector creates a Gemini AI connector in LiveReview.
func CreateGeminiConnector(result *SetupResult, geminiKey string, apiURL string) error {
apiURL = strings.TrimSpace(apiURL)
if apiURL == "" {
apiURL = CloudAPIURL
// createConnector posts a connector creation request to LiveReview, sharing
// the URL defaulting, network call, and error handling used by every
// connector-creation flow (leader, helper, ...). errLabel is used to phrase
// error messages, e.g. "connector" or "helper connector".
func createConnector(result *SetupResult, apiURL string, reqBody CreateConnectorRequest, geminiKey string, errLabel string) error {
apiURL = defaultAPIURL(apiURL)

client := network.NewSetupClient(30 * time.Second)
resp, err := network.SetupCreateConnector(client, apiURL, result.OrgID, reqBody, result.AccessToken)
if err != nil {
return fmt.Errorf("failed to create %s: %w", errLabel, err)
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return fmt.Errorf("create %s returned %d: %s", errLabel, resp.StatusCode, redactConnectorErrorBody(resp.Body, geminiKey))
}

return nil
}

// CreateGeminiConnector creates a Gemini AI connector in LiveReview.
func CreateGeminiConnector(result *SetupResult, geminiKey string, apiURL string) error {
reqBody := CreateConnectorRequest{
ProviderName: "gemini",
APIKey: geminiKey,
ConnectorName: "Gemini Flash",
SelectedModel: DefaultGeminiModel,
DisplayOrder: 0,
Role: "leader",
}

client := network.NewSetupClient(30 * time.Second)
resp, err := network.SetupCreateConnector(client, apiURL, result.OrgID, reqBody, result.AccessToken)
if err != nil {
return fmt.Errorf("failed to create connector: %w", err)
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return fmt.Errorf("create connector returned %d: %s", resp.StatusCode, redactConnectorErrorBody(resp.Body, geminiKey))
return createConnector(result, apiURL, reqBody, geminiKey, "connector")
}

// CreateGeminiHelperConnector creates a Gemini Flash-Lite helper connector in
// LiveReview, reusing the same free Gemini key as the leader connector. This
// powers Adaptive Review (see LiveReview's leader/helper model feature) out
// of the box for onboarded orgs, with no second key prompt.
func CreateGeminiHelperConnector(result *SetupResult, geminiKey string, apiURL string) error {
reqBody := CreateConnectorRequest{
ProviderName: "gemini",
APIKey: geminiKey,
ConnectorName: "Gemini Flash-Lite (Helper)",
SelectedModel: DefaultGeminiHelperModel,
DisplayOrder: 0,
Role: "helper",
}

return nil
return createConnector(result, apiURL, reqBody, geminiKey, "helper connector")
}
58 changes: 58 additions & 0 deletions setup/connectors_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package setup

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
Expand All @@ -27,3 +31,57 @@ func TestRedactConnectorErrorBody_EmptyBody(t *testing.T) {
t.Fatalf("expected empty body marker, got %q", redacted)
}
}

func TestCreateGeminiHelperConnector_SendsHelperRoleAndLiteModel(t *testing.T) {
var gotBody CreateConnectorRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to unmarshal request body: %v", err)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()

result := &SetupResult{OrgID: "1", AccessToken: "test-token"}
if err := CreateGeminiHelperConnector(result, "test-gemini-key", server.URL); err != nil {
t.Fatalf("CreateGeminiHelperConnector() error = %v", err)
}

if gotBody.Role != "helper" {
t.Errorf("expected role=%q, got %q", "helper", gotBody.Role)
}
if gotBody.SelectedModel != DefaultGeminiHelperModel {
t.Errorf("expected selected_model=%q, got %q", DefaultGeminiHelperModel, gotBody.SelectedModel)
}
if gotBody.APIKey != "test-gemini-key" {
t.Errorf("expected the same key passed to the leader connector to be reused, got %q", gotBody.APIKey)
}
}

func TestCreateGeminiConnector_SendsLeaderRole(t *testing.T) {
var gotBody CreateConnectorRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &gotBody)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()

result := &SetupResult{OrgID: "1", AccessToken: "test-token"}
if err := CreateGeminiConnector(result, "test-gemini-key", server.URL); err != nil {
t.Fatalf("CreateGeminiConnector() error = %v", err)
}

if gotBody.Role != "leader" {
t.Errorf("expected role=%q, got %q", "leader", gotBody.Role)
}
if gotBody.SelectedModel != DefaultGeminiModel {
t.Errorf("expected selected_model=%q, got %q", DefaultGeminiModel, gotBody.SelectedModel)
}
}
3 changes: 2 additions & 1 deletion setup/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const (
CloudAPIURL = "https://livereview.hexmos.com"
HexmosSigninBase = "https://hexmos.com/signin"
GeminiKeysURL = "https://aistudio.google.com/api-keys"
DefaultGeminiModel = "gemini-2.5-flash"
DefaultGeminiModel = "gemini-2.5-flash"
DefaultGeminiHelperModel = "gemini-2.5-flash-lite"
IssuesURL = "https://github.com/HexmosTech/git-lrc/issues/new"
)
3 changes: 3 additions & 0 deletions setup/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,7 @@ type CreateConnectorRequest struct {
SelectedModel string `json:"selected_model"`
DisplayOrder int `json:"display_order"`
BaseURL string `json:"base_url,omitempty"`
// Role is "leader" or "helper" (see LiveReview's Adaptive Review
// feature). Omitted/empty defaults to "leader" server-side.
Role string `json:"role,omitempty"`
}
8 changes: 6 additions & 2 deletions ui/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ type ConnectorRemote struct {
GCPLocation string `json:"gcp_location"`
SelectedModel string `json:"selected_model"`
DisplayOrder int `json:"display_order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// Role is "leader" or "helper" (LiveReview's Adaptive Review feature).
// The backend always includes this on connector responses; empty here
// just means an old/unexpected payload, and should be treated as "leader".
Role string `json:"role"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

type AuthRefreshRequest struct {
Expand Down
Loading