Repository navigation
feat(sync): integrate repository sources with planning and link #995
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "maps" | ||
| "net/http" | ||
| "net/url" | ||
| "slices" | ||
| "strings" | ||
|
|
||
| syncdomain "github.com/launchdarkly/ldcli/internal/sync" | ||
| ) | ||
|
|
||
| // syncSettingsPageLimit is the page size of the sync settings endpoint. It | ||
| // matches the most resources that one call accepts. | ||
| const syncSettingsPageLimit = 100 | ||
|
|
||
| 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. A resource of a kind that LaunchDarkly has no setting for is left | ||
| // out, and the caller decides what a missing setting means. | ||
| 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") | ||
| } | ||
| byKind := map[syncdomain.Kind][]syncdomain.ResourceID{} | ||
| for _, id := range ids { | ||
| if id.ProjectKey != projectKey { | ||
| return nil, fmt.Errorf("read sync settings: %s is not in project %q", id.Label(), projectKey) | ||
| } | ||
| byKind[id.Kind] = append(byKind[id.Kind], id) | ||
| } | ||
| result := make(map[syncdomain.ResourceID]syncdomain.SyncDirection, len(ids)) | ||
| // The endpoint rejects a request that names a kind it has no setting for, | ||
| // and the rejection fails every resource of the request. Each kind travels | ||
| // alone, so a tool or a skill cannot take the variations down with it. | ||
| for _, kind := range slices.Sorted(maps.Keys(byKind)) { | ||
| directions, err := client.syncDirectionsOfKind(projectKey, byKind[kind]) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| maps.Copy(result, directions) | ||
| } | ||
| return result, nil | ||
| } | ||
|
|
||
| // syncDirectionsOfKind reads the settings of resources of one kind. A kind | ||
| // that LaunchDarkly rejects as unsupported has no settings. | ||
| func (client Client) syncDirectionsOfKind( | ||
| projectKey string, | ||
| ids []syncdomain.ResourceID, | ||
| ) (map[syncdomain.ResourceID]syncdomain.SyncDirection, error) { | ||
| query := url.Values{} | ||
| for _, id := range ids { | ||
| query.Add("resources", string(id.Kind)+":"+id.LookupKey) | ||
| } | ||
| result := make(map[syncdomain.ResourceID]syncdomain.SyncDirection, len(ids)) | ||
| // The endpoint pages its items. Read every page, so that a batch of more | ||
| // than one page of resources does not look like missing settings. | ||
| for offset := 0; ; { | ||
| page, err := client.readSyncSettingsPage(projectKey, query, offset) | ||
| if unsupportedResourceKind(err) { | ||
| return result, nil | ||
| } | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| for _, item := range page.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 | ||
| } | ||
| offset += len(page.Items) | ||
| // The total count ends the paging when the response has one. Without | ||
| // it, a short page is the last one. | ||
| switch { | ||
| case len(page.Items) == 0: | ||
| return result, nil | ||
| case page.TotalCount > 0 && offset >= page.TotalCount: | ||
| return result, nil | ||
| case page.TotalCount == 0 && len(page.Items) < syncSettingsPageLimit: | ||
| return result, nil | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // unsupportedResourceKind reports the 400 response of the sync settings | ||
| // endpoint for a resource kind that LaunchDarkly has no setting for. | ||
| func unsupportedResourceKind(err error) bool { | ||
| response, ok := responseError(err) | ||
| if !ok { | ||
| return false | ||
| } | ||
| status, _ := response["statusCode"].(float64) | ||
| message, _ := response["message"].(string) | ||
| return int(status) == http.StatusBadRequest && strings.Contains(strings.ToLower(message), "unsupported resource kind") | ||
| } | ||
|
|
||
| // readSyncSettingsPage reads one page of resolved settings. A response | ||
| // without a body is an empty page. | ||
| func (client Client) readSyncSettingsPage(projectKey string, query url.Values, offset int) (Page[resolvedSetting], error) { | ||
| page := Page[resolvedSetting]{} | ||
| values := pageQuery(syncSettingsPageLimit, offset) | ||
| for key, value := range query { | ||
| values[key] = value | ||
| } | ||
| response, err := client.read( | ||
| fmt.Sprintf("get sync settings in project %q", projectKey), | ||
| projectKey, | ||
| values, | ||
| projectPath(projectKey, "configs", "sync", "settings")..., | ||
| ) | ||
| if err != nil || len(response) == 0 { | ||
| return page, err | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| return decodeJSON[Page[resolvedSetting]](response, "sync settings response") | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,132 @@ | ||||||||||||||||||||||||||||||||||||||||
| package api | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||
| "errors" | ||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||||||||||||
| "testing" | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| "github.com/stretchr/testify/assert" | ||||||||||||||||||||||||||||||||||||||||
| "github.com/stretchr/testify/require" | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| syncdomain "github.com/launchdarkly/ldcli/internal/sync" | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // The endpoint rejects a request that names a kind it has no setting for, | ||||||||||||||||||||||||||||||||||||||||
| // and the rejection fails every resource of the request, so each kind is | ||||||||||||||||||||||||||||||||||||||||
| // asked for alone. | ||||||||||||||||||||||||||||||||||||||||
| func TestSyncDirectionsAsksForEachKindAlone(t *testing.T) { | ||||||||||||||||||||||||||||||||||||||||
| transport := &recordingClient{Responses: [][]byte{ | ||||||||||||||||||||||||||||||||||||||||
| []byte(`{"items": [{"resourceKind":"tool","resourceLookupKey":"search","direction":"server_canonical"}]}`), | ||||||||||||||||||||||||||||||||||||||||
| []byte(`{"items": [{"resourceKind":"variation","resourceLookupKey":"support/default","direction":"code_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, 2, "one request per kind, in kind order") | ||||||||||||||||||||||||||||||||||||||||
| for _, request := range transport.Requests { | ||||||||||||||||||||||||||||||||||||||||
| 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{"tool:search"}, transport.Requests[0].Query["resources"]) | ||||||||||||||||||||||||||||||||||||||||
| assert.Equal(t, []string{"variation:support/default"}, transport.Requests[1].Query["resources"]) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // LaunchDarkly answers 400 "unsupported resource kind" for a kind that has no | ||||||||||||||||||||||||||||||||||||||||
| // sync setting yet. The variations of the same call still get their settings, | ||||||||||||||||||||||||||||||||||||||||
| // and the resources of the rejected kind have none. | ||||||||||||||||||||||||||||||||||||||||
| func TestSyncDirectionsTreatsAnUnsupportedKindAsHavingNoSetting(t *testing.T) { | ||||||||||||||||||||||||||||||||||||||||
| unsupported := errors.New(`{"code":"invalid_request","message":"unsupported resource kind","statusCode":400}`) | ||||||||||||||||||||||||||||||||||||||||
| // The kinds are asked for in name order: skill, tool, variation. | ||||||||||||||||||||||||||||||||||||||||
| transport := &recordingClient{ | ||||||||||||||||||||||||||||||||||||||||
| Errors: []error{unsupported, unsupported, nil}, | ||||||||||||||||||||||||||||||||||||||||
| Responses: [][]byte{nil, nil, | ||||||||||||||||||||||||||||||||||||||||
| []byte(`{"items": [{"resourceKind":"variation","resourceLookupKey":"support/default","direction":"both"}]}`), | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| client := NewClient(transport, "token", "https://example.com") | ||||||||||||||||||||||||||||||||||||||||
| variation := syncdomain.VariationID("project", "support", "default") | ||||||||||||||||||||||||||||||||||||||||
| tool := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"} | ||||||||||||||||||||||||||||||||||||||||
| skill := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"} | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| directions, err := client.SyncDirections("project", []syncdomain.ResourceID{variation, tool, skill}) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| require.NoError(t, err, "a kind without settings does not fail the variations") | ||||||||||||||||||||||||||||||||||||||||
| assert.Equal(t, map[syncdomain.ResourceID]syncdomain.SyncDirection{variation: syncdomain.SyncDirectionBoth}, directions) | ||||||||||||||||||||||||||||||||||||||||
| require.Len(t, transport.Requests, 3) | ||||||||||||||||||||||||||||||||||||||||
| assert.Equal(t, []string{"variation:support/default"}, transport.Requests[2].Query["resources"]) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| other := errors.New(`{"code":"invalid_request","message":"resource key is not valid","statusCode":400}`) | ||||||||||||||||||||||||||||||||||||||||
| transport = &recordingClient{Errors: []error{other}} | ||||||||||||||||||||||||||||||||||||||||
| client = NewClient(transport, "token", "https://example.com") | ||||||||||||||||||||||||||||||||||||||||
| _, err = client.SyncDirections("project", []syncdomain.ResourceID{tool}) | ||||||||||||||||||||||||||||||||||||||||
| require.Error(t, err, "another 400 still fails the call") | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| func TestSyncDirectionsReadsEveryPage(t *testing.T) { | ||||||||||||||||||||||||||||||||||||||||
| transport := &recordingClient{Responses: [][]byte{ | ||||||||||||||||||||||||||||||||||||||||
| []byte(`{"items": [ | ||||||||||||||||||||||||||||||||||||||||
| {"resourceKind":"tool","resourceLookupKey":"first","direction":"both"}, | ||||||||||||||||||||||||||||||||||||||||
| {"resourceKind":"tool","resourceLookupKey":"second","direction":"both"} | ||||||||||||||||||||||||||||||||||||||||
| ], "totalCount": 3}`), | ||||||||||||||||||||||||||||||||||||||||
| []byte(`{"items": [ | ||||||||||||||||||||||||||||||||||||||||
| {"resourceKind":"tool","resourceLookupKey":"third","direction":"code_canonical"} | ||||||||||||||||||||||||||||||||||||||||
| ], "totalCount": 3}`), | ||||||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||||||
| client := NewClient(transport, "token", "https://example.com") | ||||||||||||||||||||||||||||||||||||||||
| ids := make([]syncdomain.ResourceID, 0, 3) | ||||||||||||||||||||||||||||||||||||||||
| for _, key := range []string{"first", "second", "third"} { | ||||||||||||||||||||||||||||||||||||||||
| ids = append(ids, syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: key}) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| directions, err := client.SyncDirections("project", ids) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| require.NoError(t, err) | ||||||||||||||||||||||||||||||||||||||||
| assert.Len(t, directions, 3) | ||||||||||||||||||||||||||||||||||||||||
| assert.Equal(t, syncdomain.SyncDirectionCodeCanonical, directions[ids[2]]) | ||||||||||||||||||||||||||||||||||||||||
| require.Len(t, transport.Requests, 2) | ||||||||||||||||||||||||||||||||||||||||
| assert.Equal(t, []string{"0"}, transport.Requests[0].Query["offset"]) | ||||||||||||||||||||||||||||||||||||||||
| assert.Equal(t, []string{"2"}, transport.Requests[1].Query["offset"]) | ||||||||||||||||||||||||||||||||||||||||
| assert.Equal(t, []string{"100"}, transport.Requests[1].Query["limit"]) | ||||||||||||||||||||||||||||||||||||||||
| assert.Equal(t, []string{"tool:first", "tool:second", "tool:third"}, transport.Requests[1].Query["resources"]) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| func TestSyncDirectionsPagesWithoutATotalCount(t *testing.T) { | ||||||||||||||||||||||||||||||||||||||||
| full := make([]string, 0, syncSettingsPageLimit) | ||||||||||||||||||||||||||||||||||||||||
| ids := make([]syncdomain.ResourceID, 0, syncSettingsPageLimit) | ||||||||||||||||||||||||||||||||||||||||
| for index := range syncSettingsPageLimit { | ||||||||||||||||||||||||||||||||||||||||
| key := fmt.Sprintf("tool%d", index) | ||||||||||||||||||||||||||||||||||||||||
| full = append(full, fmt.Sprintf(`{"resourceKind":"tool","resourceLookupKey":%q,"direction":"both"}`, key)) | ||||||||||||||||||||||||||||||||||||||||
| ids = append(ids, syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: key}) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| transport := &recordingClient{Responses: [][]byte{ | ||||||||||||||||||||||||||||||||||||||||
| []byte(`{"items": [` + strings.Join(full, ",") + `]}`), | ||||||||||||||||||||||||||||||||||||||||
| []byte(`{"items": []}`), | ||||||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||||||
| client := NewClient(transport, "token", "https://example.com") | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| directions, err := client.SyncDirections("project", ids) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| require.NoError(t, err) | ||||||||||||||||||||||||||||||||||||||||
| assert.Len(t, directions, syncSettingsPageLimit) | ||||||||||||||||||||||||||||||||||||||||
| require.Len(t, transport.Requests, 2, "a full page without a total count is followed by a request for the next page") | ||||||||||||||||||||||||||||||||||||||||
| assert.Equal(t, []string{"100"}, transport.Requests[1].Query["offset"]) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a single GET to the sync settings endpoint asks for both a variation and a tool, we return a '400 unsupported resource kind' and the whole call fails, variation included. The test below is currently failing. Should we even be supporting both tools and skills here, or should the client only send variations? If this is intended behavior lmk and I can stamp!
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you, that was a real failure: one 400 for the tool took the variations down with it. |
||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.