Skip to content
Draft
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
69 changes: 69 additions & 0 deletions internal/sync/api/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package api

import (
"fmt"
"net/url"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

type settingsResponse struct {
Items []resolvedSetting `json:"items"`
}

type resolvedSetting struct {
ResourceKind syncdomain.Kind `json:"resourceKind"`
ResourceLookupKey string `json:"resourceLookupKey"`
Direction syncdomain.SyncDirection `json:"direction"`
}

// SyncDirections returns the effective LaunchDarkly setting for each resource.
func (client Client) SyncDirections(
projectKey string,
ids []syncdomain.ResourceID,
) (map[syncdomain.ResourceID]syncdomain.SyncDirection, error) {
if len(ids) == 0 {
return map[syncdomain.ResourceID]syncdomain.SyncDirection{}, nil
}
if len(ids) > 100 {
return nil, fmt.Errorf("read sync settings: at most 100 resources are allowed")
}
query := url.Values{}
for _, id := range ids {
if id.ProjectKey != projectKey {
return nil, fmt.Errorf("read sync settings: %s is not in project %q", id.Label(), projectKey)
}
query.Add("resources", string(id.Kind)+":"+id.LookupKey)
}
response, err := client.read(
fmt.Sprintf("get sync settings in project %q", projectKey),
projectKey,
query,
projectPath(projectKey, "configs", "sync", "settings")...,
)
if err != nil {
return nil, err
}
if len(response) == 0 {
return map[syncdomain.ResourceID]syncdomain.SyncDirection{}, nil
}
settings, err := decodeJSON[settingsResponse](response, "sync settings response")
if err != nil {
return nil, err
}
result := make(map[syncdomain.ResourceID]syncdomain.SyncDirection, len(settings.Items))
for _, item := range settings.Items {
id := syncdomain.ResourceID{
Kind: item.ResourceKind, ProjectKey: projectKey, LookupKey: item.ResourceLookupKey,
}
if !item.Direction.Valid() {
return nil, syncdomain.NewProblem(
syncdomain.ErrorSyncSettingUnavailable,
fmt.Sprintf("LaunchDarkly returned unsupported direction %q", item.Direction),
"Configure a supported sync setting in LaunchDarkly.",
).WithResource(id)
}
result[id] = item.Direction
}
return result, nil
}
44 changes: 44 additions & 0 deletions internal/sync/api/settings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package api

import (
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

func TestSyncDirections(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"items": [
{"resourceKind":"variation","resourceLookupKey":"support/default","direction":"code_canonical"},
{"resourceKind":"tool","resourceLookupKey":"search","direction":"server_canonical"}
]
}`)}}
client := NewClient(transport, "token", "https://example.com")
variation := syncdomain.VariationID("project", "support", "default")
tool := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"}

directions, err := client.SyncDirections("project", []syncdomain.ResourceID{variation, tool})
require.NoError(t, err)
assert.Equal(t, syncdomain.SyncDirectionCodeCanonical, directions[variation])
assert.Equal(t, syncdomain.SyncDirectionServerCanonical, directions[tool])
require.Len(t, transport.Requests, 1)
request := transport.Requests[0]
assert.Equal(t, http.MethodGet, request.Method)
assert.Equal(t, "https://example.com/api/v2/projects/project/configs/sync/settings", request.Path)
assert.Equal(t, []string{"variation:support/default", "tool:search"}, request.Query["resources"])
}

func TestSyncDirectionsReturnsOnlyResolvedResults(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{"items":[]}`)}}
client := NewClient(transport, "token", "https://example.com")
id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"}

directions, err := client.SyncDirections("project", []syncdomain.ResourceID{id})

require.NoError(t, err)
assert.Empty(t, directions)
}
2 changes: 0 additions & 2 deletions internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1287,8 +1287,6 @@ func TestPromptDetachArchive(t *testing.T) {
}
}

// syncLinkedVariation writes a linked variation that matches LaunchDarkly and
// runs one sync, so that sync.lock records the link.
func TestPromptFilterSyncsOnlyTheNamedVariation(t *testing.T) {
root := initRepository(t)
baseline := variation("Baseline")
Expand Down
19 changes: 18 additions & 1 deletion internal/sync/prompt/attachment_versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ func (resolver *attachmentResolver) settle(id ResourceID, attachment syncdomain.
// before it pulled. If a tool or skill of the row failed, the row fails too.
func (resolver *attachmentResolver) settledRow(resource PlannedResource) (PlannedResource, error) {
side, fingerprint := &resource.Local, &resource.LocalFingerprint
localSide := true
switch {
case resource.Action == ActionUpdateLocal:
side, fingerprint = &resource.Server, &resource.ServerFingerprint
localSide = false
case !resource.Action.changesServer():
return resource, nil
}
Expand All @@ -67,7 +69,22 @@ func (resolver *attachmentResolver) settledRow(resource PlannedResource) (Planne
}
variation := **side
variation.Attachments = attachments
value, err := syncdomain.FingerprintVariation(resource.ID.ProjectKey, resource.ID.LookupKey, variation)
var value string
var err error
if resource.Source != nil {
projection, projectionErr := syncdomain.ProjectVariation(variation, *resource.Source)
if projectionErr != nil {
return resource, projectionErr
}
value, err = syncdomain.FingerprintProjection(resource.ID, projection)
if localSide {
resource.LocalProjection = projection
} else {
resource.ServerProjection = projection
}
} else {
value, err = syncdomain.FingerprintVariation(resource.ID.ProjectKey, resource.ID.LookupKey, variation)
}
if err != nil {
return resource, err
}
Expand Down
156 changes: 156 additions & 0 deletions internal/sync/prompt/capability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package prompt

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

func TestUnchangedReadOnlyToolFieldBlocksCompleteResource(t *testing.T) {
id := ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup"}
oldDescription, newDescription := "Old", "New"
schema := map[string]any{"type": "object"}
reference := &syncdomain.Reference{
File: "tools.ts", Format: "typescript",
Map: map[string]string{"description": "description", "schema": "inputSchema"},
}
server := syncdomain.Attachment{
Kind: syncdomain.AttachmentTool,
Tool: &syncdomain.Tool{Key: "lookup", Description: &oldDescription, Schema: schema},
}
serverProjection, err := syncdomain.ProjectAttachment(server, *reference)
require.NoError(t, err)
baseline, err := syncdomain.FingerprintProjection(id, serverProjection)
require.NoError(t, err)
local := syncdomain.SyncedAttachment{
ProjectKey: "project",
Attachment: syncdomain.Attachment{
Kind: syncdomain.AttachmentTool,
Tool: &syncdomain.Tool{Key: "lookup", Description: &newDescription},
},
Source: reference,
Projection: syncdomain.ToolProjection{
Description: &newDescription,
FieldStates: map[syncdomain.Field]syncdomain.FieldState{
syncdomain.FieldDescription: {Capability: syncdomain.CapabilityDirect, Known: true},
syncdomain.FieldSchema: {
Capability: syncdomain.CapabilityReadOnly, Reason: "helper call",
},
},
},
}

resource := planSyncedAttachment(id, &local, &server, baseline, true)

assert.Equal(t, ActionError, resource.Action)
require.NotNil(t, resource.Problem)
assert.Equal(t, syncdomain.ErrorFieldReadOnly, resource.Problem.Code)
assert.Contains(t, resource.Problem.Message, `field "schema"`)
assert.Equal(t, schema, *resource.LocalProjection.(syncdomain.ToolProjection).Schema)
state, ok := syncdomain.ProjectionFieldState(resource.LocalProjection, syncdomain.FieldSchema)
require.True(t, ok)
assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability)
assert.False(t, state.Known)

local.Attachment.Tool.Description = &oldDescription
local.Projection = syncdomain.ToolProjection{
Description: &oldDescription,
FieldStates: map[syncdomain.Field]syncdomain.FieldState{
syncdomain.FieldDescription: {Capability: syncdomain.CapabilityDirect, Known: true},
syncdomain.FieldSchema: {
Capability: syncdomain.CapabilityReadOnly, Reason: "helper call",
},
},
}
server.Tool.Description = &newDescription
resource = planSyncedAttachment(id, &local, &server, baseline, true)
assert.Equal(t, ActionError, resource.Action)
require.NotNil(t, resource.Problem)
assert.Equal(t, syncdomain.ErrorFieldReadOnly, resource.Problem.Code)
}

func TestDynamicPromptBlocksVariationPush(t *testing.T) {
id := syncdomain.VariationID("project", "config", "default")
reference := &syncdomain.Reference{
File: "agents.py", Format: "python",
Map: map[string]string{"prompt": "instructions", "model": "model"},
}
oldModel, newModel, serverPrompt := "gpt-4", "gpt-5", "Server prompt"
server := syncdomain.Variation{
Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default",
Instructions: serverPrompt, Model: map[string]any{"modelName": oldModel},
}
serverProjection, err := syncdomain.ProjectVariation(server, *reference)
require.NoError(t, err)
baseline, err := syncdomain.FingerprintProjection(id, serverProjection)
require.NoError(t, err)
local := syncdomain.SyncedResource{
Kind: id.Kind, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey,
Source: reference,
Variation: syncdomain.Variation{
Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default",
Model: map[string]any{"modelName": newModel},
},
Projection: syncdomain.VariationProjection{
Mode: syncdomain.VariationModeAgent, Model: &newModel,
FieldStates: map[syncdomain.Field]syncdomain.FieldState{
syncdomain.FieldPrompt: {
Capability: syncdomain.CapabilityReadOnly, Reason: "f-string",
},
syncdomain.FieldModel: {Capability: syncdomain.CapabilityDirect, Known: true},
},
},
}

resource := planResource(
id, &local, ServerResource{Variation: &server, ConfigMode: syncdomain.VariationModeAgent},
baseline, true,
)

assert.Equal(t, ActionError, resource.Action)
require.NotNil(t, resource.Problem)
assert.Equal(t, syncdomain.ErrorFieldReadOnly, resource.Problem.Code)
assert.Contains(t, resource.Problem.Message, `field "prompt"`)
}

func TestUnresolvedImportSuggestsExactLinkCommand(t *testing.T) {
id := syncdomain.VariationID("acme", "support", "default")
reference := &syncdomain.Reference{
File: "app/agent.py", Format: "python", Map: map[string]string{"prompt": "instructions"},
}
server := syncdomain.Variation{
Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default",
Instructions: "Server prompt",
}
local := syncdomain.SyncedResource{
Kind: id.Kind, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey, Source: reference,
Variation: syncdomain.Variation{
Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default",
},
Projection: syncdomain.VariationProjection{
Mode: syncdomain.VariationModeAgent,
FieldStates: map[syncdomain.Field]syncdomain.FieldState{
syncdomain.FieldPrompt: {
Capability: syncdomain.CapabilityReadOnly,
Reason: "import cannot be resolved", Code: syncdomain.ErrorImportUnresolved,
File: "app/prompts.py",
},
},
},
}

resource := planResource(
id, &local, ServerResource{Variation: &server, ConfigMode: syncdomain.VariationModeAgent},
"", false,
)

require.NotNil(t, resource.Problem)
assert.Equal(t, syncdomain.ErrorImportUnresolved, resource.Problem.Code)
assert.Equal(t,
"Link the imported value directly: ldcli sync prompts link --file app/prompts.py --format python --project acme --config-key support --key default",
resource.Problem.Suggestion,
)
}
9 changes: 9 additions & 0 deletions internal/sync/prompt/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ func attachmentDiff(before, after *syncdomain.Attachment) variationDiffFields {
return diffParts(nil, nil, beforeAttachments, afterAttachments)
}

func projectionDiff(before, after syncdomain.Projection) variationDiffFields {
beforeJSON, _ := json.Marshal(before)
afterJSON, _ := json.Marshal(after)
if string(beforeJSON) == string(afterJSON) {
return nil
}
return variationDiffFields{"projection": {Before: beforeJSON, After: afterJSON}}
}

// diffParts returns each part that differs, or nil.
func diffParts(beforeVariation, afterVariation json.RawMessage, beforeAttachments, afterAttachments []syncdomain.Attachment) variationDiffFields {
fields := variationDiffFields{}
Expand Down
Loading
Loading