From b4418297d2e0dc250d2e023a56734e65205d6750 Mon Sep 17 00:00:00 2001 From: Cliff Tawiah Date: Thu, 22 Oct 2026 21:00:04 +0000 Subject: [PATCH] feat: add tools and skills from LaunchDarkly The add of the bootstrap package can now add tools and skills, as it adds variations. With Kind set to tool or skill, Run reads each selected tool or skill from LaunchDarkly, writes its file, and records its sync.lock and remote manifest entries. Without a selection, it asks for the project and then for one tool or skill, with the search picker of attach. A dry run lists the files. Add refuses a key that LaunchDarkly does not have, a key that is already synced, and a key that has a sync.lock entry but no file. It checks every key before it writes. If the baseline save fails, it removes the new files. The tool and skill picker moves from the prompt package to the interactive package, so that bootstrap and attach share it. No command reaches the new add yet. --- internal/sync/bootstrap/attachments.go | 142 +++++++++++++++ internal/sync/bootstrap/attachments_test.go | 191 ++++++++++++++++++++ internal/sync/bootstrap/bootstrap.go | 27 ++- internal/sync/bootstrap/bootstrap_test.go | 1 + internal/sync/interactive/catalog.go | 51 ++++++ internal/sync/local/attachment.go | 10 + internal/sync/local/layout.go | 10 + internal/sync/local/replace.go | 8 +- internal/sync/local/store.go | 19 +- internal/sync/prompt/attach.go | 44 +---- internal/sync/prompt/runner_test.go | 14 +- 11 files changed, 450 insertions(+), 67 deletions(-) create mode 100644 internal/sync/bootstrap/attachments.go create mode 100644 internal/sync/bootstrap/attachments_test.go diff --git a/internal/sync/bootstrap/attachments.go b/internal/sync/bootstrap/attachments.go new file mode 100644 index 000000000..c08207d34 --- /dev/null +++ b/internal/sync/bootstrap/attachments.go @@ -0,0 +1,142 @@ +package bootstrap + +import ( + "errors" + "fmt" + "slices" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +// addAttachments writes the files of the selected tools or skills from +// LaunchDarkly and records their baseline. It checks and reads every +// selection before it writes, so an error writes nothing. +func addAttachments(options Options) error { + selections := options.Selections + if len(selections) == 0 { + var canceled bool + var err error + if selections, canceled, err = promptForAttachment(options); err != nil || canceled { + return err + } + } + attachments, err := readNewAttachments(options, selections) + if err != nil { + return err + } + if options.DryRun { + previews := make([]synclocal.RenderedFile, 0, len(attachments)) + for _, attachment := range attachments { + preview, err := synclocal.RenderAttachment(attachment) + if err != nil { + return err + } + previews = append(previews, preview) + } + writePreviews(options.Output, previews, options.Diff) + return nil + } + return writeAttachments(options, attachments) +} + +// promptForAttachment asks the user for a project and for one tool or skill. +// ProjectKey skips the project question. The bool result is true when the +// user cancels. +func promptForAttachment(options Options) ([]syncdomain.ResourceID, bool, error) { + kind := syncdomain.AttachmentKind(options.Kind) + arguments := "--project and --key" + if options.ProjectKey != "" { + arguments = "--key values" + } + if err := syncinteractive.RequireTerminal(options.Input, options.Output, options.NoInput, arguments, string(kind)+" selection"); err != nil { + return nil, false, err + } + + projectKey := options.ProjectKey + if projectKey == "" { + project, canceled, err := syncinteractive.SelectProject(options.Input, options.Output, options.Catalog) + if err != nil || canceled { + return nil, canceled, err + } + projectKey = project.Key + } + attachment, canceled, err := syncinteractive.SelectAttachment(options.Input, options.Output, options.Attachments, projectKey, kind) + if err != nil || canceled { + return nil, canceled, err + } + return []syncdomain.ResourceID{attachment.ID(projectKey)}, false, nil +} + +// readNewAttachments reads each selected tool or skill from LaunchDarkly. A +// selection that this repository already syncs, or that LaunchDarkly does not +// have, is an error. The local checks come first, so they need no read. +func readNewAttachments(options Options, selections []syncdomain.ResourceID) ([]syncdomain.SyncedAttachment, error) { + lock, err := syncmanifest.ReadLock(options.Store) + if err != nil { + return nil, err + } + for _, id := range selections { + exists, err := options.Store.AttachmentExists(id) + switch { + case err != nil: + return nil, err + case exists: + path, err := synclocal.AttachmentPath(id) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("%s is already synced. Edit %s and run ldcli sync %ss.", id.Label(), path, id.Kind) + case slices.ContainsFunc(lock.Resources, func(resource syncmanifest.Resource) bool { return resource.ID() == id }): + return nil, fmt.Errorf("%s has a sync.lock entry but no local file. Run ldcli sync %ss to restore it.", id.Label(), id.Kind) + } + } + + attachments := make([]syncdomain.SyncedAttachment, 0, len(selections)) + for _, id := range selections { + attachment, err := options.Attachments.ReadAttachment(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey) + if syncapi.IsNotFound(err) { + return nil, fmt.Errorf("%s does not exist in LaunchDarkly.", id.Label()) + } + if err != nil { + return nil, err + } + attachments = append(attachments, syncdomain.SyncedAttachment{ProjectKey: id.ProjectKey, Attachment: attachment}) + } + return attachments, nil +} + +// writeAttachments writes the files and records their baseline. If the +// baseline save fails, it removes the files again. +func writeAttachments(options Options, attachments []syncdomain.SyncedAttachment) error { + projectKeys := make([]string, 0, len(attachments)) + for _, attachment := range attachments { + projectKeys = append(projectKeys, attachment.ProjectKey) + } + baseline, err := options.Baselines.Load(projectKeys) + if err != nil { + return err + } + + paths, err := options.Store.ReplaceAttachments(attachments) + if err != nil { + return err + } + next := baseline.Lock.Clone() + for _, attachment := range attachments { + if err = next.SetAttachments(attachment.ProjectKey, []syncdomain.Attachment{attachment.Attachment}); err != nil { + break + } + } + if err == nil { + _, err = options.Baselines.Save(baseline, next) + } + if err != nil { + return errors.Join(err, options.Store.RollbackCreation(synclocal.Creation{CreatedPaths: paths})) + } + writeSummary(options.Output, options.Initial, len(paths), string(options.Kind)) + return nil +} diff --git a/internal/sync/bootstrap/attachments_test.go b/internal/sync/bootstrap/attachments_test.go new file mode 100644 index 000000000..b6e16caad --- /dev/null +++ b/internal/sync/bootstrap/attachments_test.go @@ -0,0 +1,191 @@ +package bootstrap + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +func TestRunAddsToolsAndSkillsFromLaunchDarkly(t *testing.T) { + for _, test := range []struct { + kind syncdomain.Kind + initial bool + wantOutput string + }{ + {kind: syncdomain.KindTool, initial: true, wantOutput: "Bootstrapped 1 tool file in .launchdarkly.\n"}, + {kind: syncdomain.KindSkill, wantOutput: "Added 1 skill file in .launchdarkly.\n"}, + } { + t.Run(string(test.kind), func(t *testing.T) { + root := t.TempDir() + attachment := testAttachment(test.kind) + id := attachment.ID("project") + baselines := &memoryManifestStore{manifest: syncmanifest.New()} + var output bytes.Buffer + + err := Run(Options{ + Kind: test.kind, Attachments: &fakeAttachments{attachments: []syncdomain.Attachment{attachment}}, + Store: synclocal.NewStore(root), Baselines: baselines, Output: &output, Initial: test.initial, + Selections: []syncdomain.ResourceID{id}, NoInput: true, + }) + + require.NoError(t, err) + assert.Equal(t, test.wantOutput, output.String()) + local, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + require.Len(t, local, 1) + assert.True(t, syncdomain.SameAttachmentContent(attachment, local[0].Attachment)) + fingerprint, err := syncdomain.FingerprintAttachment("project", attachment) + require.NoError(t, err) + assert.Equal(t, []syncmanifest.Resource{{ + ResourceKind: test.kind, ProjectKey: "project", LookupKey: id.LookupKey, Fingerprint: fingerprint, + }}, baselines.manifest.Resources) + }) + } +} + +func TestRunRefusesAToolBeforeAnyWrite(t *testing.T) { + search := testAttachment(syncdomain.KindTool) + id := search.ID("project") + tests := map[string]struct { + local bool + tracked bool + launchDarkly []syncdomain.Attachment + want string + }{ + "missing in LaunchDarkly": {want: "tool project/search does not exist in LaunchDarkly."}, + "already synced": { + local: true, launchDarkly: []syncdomain.Attachment{search}, + want: "tool project/search is already synced. Edit .launchdarkly/project/tools/search.json and run ldcli sync tools.", + }, + "lock entry without a file": { + tracked: true, launchDarkly: []syncdomain.Attachment{search}, + want: "tool project/search has a sync.lock entry but no local file. Run ldcli sync tools to restore it.", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + if test.local { + _, err := store.ReplaceAttachments([]syncdomain.SyncedAttachment{{ProjectKey: "project", Attachment: search}}) + require.NoError(t, err) + } + if test.tracked { + require.NoError(t, os.MkdirAll(filepath.Join(root, syncdomain.RootDir), 0o755)) + lock := syncmanifest.New() + require.NoError(t, lock.SetAttachments("project", []syncdomain.Attachment{search})) + require.NoError(t, syncmanifest.WriteLock(store, lock)) + } + baselines := &memoryManifestStore{manifest: syncmanifest.New()} + + err := Run(Options{ + Kind: syncdomain.KindTool, Attachments: &fakeAttachments{attachments: test.launchDarkly}, + Store: store, Baselines: baselines, Output: &bytes.Buffer{}, Selections: []syncdomain.ResourceID{id}, + }) + + require.EqualError(t, err, test.want) + assert.Empty(t, baselines.manifest.Resources) + files, err := synclocal.SourceFiles(root) + require.NoError(t, err) + if !test.local { + assert.NotContains(t, files, ".launchdarkly/project/tools/search.json") + } + }) + } +} + +func TestRunPreviewsToolsAndSkillsInADryRun(t *testing.T) { + root := t.TempDir() + skill := testAttachment(syncdomain.KindSkill) + var output bytes.Buffer + + err := Run(Options{ + Kind: syncdomain.KindSkill, Attachments: &fakeAttachments{attachments: []syncdomain.Attachment{skill}}, + Store: synclocal.NewStore(root), Baselines: &memoryManifestStore{manifest: syncmanifest.New()}, + Output: &output, DryRun: true, Diff: true, Selections: []syncdomain.ResourceID{skill.ID("project")}, + }) + + require.NoError(t, err) + assert.Equal(t, `1 file to create: + .launchdarkly/project/skills/summarize.md +6 + +.launchdarkly/project/skills/summarize.md + --- + key: summarize + description: Summarize a ticket + --- + + Write three bullets. +`, output.String()) + _, err = os.Stat(filepath.Join(root, syncdomain.RootDir)) + require.ErrorIs(t, err, os.ErrNotExist, "a dry run writes nothing") +} + +func TestRunRemovesNewToolFilesWhenTheBaselineSaveFails(t *testing.T) { + root := t.TempDir() + search := testAttachment(syncdomain.KindTool) + + err := Run(Options{ + Kind: syncdomain.KindTool, Attachments: &fakeAttachments{attachments: []syncdomain.Attachment{search}}, + Store: synclocal.NewStore(root), Baselines: failingManifestStore{}, Output: &bytes.Buffer{}, + Selections: []syncdomain.ResourceID{search.ID("project")}, + }) + + require.EqualError(t, err, "write manifest") + _, err = os.Stat(filepath.Join(root, syncdomain.RootDir)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRunNamesTheToolFlagsWithoutInput(t *testing.T) { + options := Options{ + Kind: syncdomain.KindTool, Attachments: &fakeAttachments{}, Store: synclocal.NewStore(t.TempDir()), + Input: &bytes.Buffer{}, Output: &bytes.Buffer{}, NoInput: true, + } + require.EqualError(t, Run(options), "--project and --key are required with --no-input") + + options.ProjectKey = "project" + require.EqualError(t, Run(options), "--key values are required with --no-input") +} + +func testAttachment(kind syncdomain.Kind) syncdomain.Attachment { + if kind == syncdomain.KindSkill { + return syncdomain.Attachment{Kind: syncdomain.AttachmentSkill, Version: 2, Skill: &syncdomain.Skill{ + Key: "summarize", Name: "Summarize", Description: "Summarize a ticket", Markdown: "Write three bullets.\n", + }} + } + description := "Search documentation" + return syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 4, Tool: &syncdomain.Tool{ + Key: "search", Description: &description, Schema: map[string]any{"type": "object"}, + }} +} + +// fakeAttachments is LaunchDarkly with the given tools and skills. +type fakeAttachments struct { + attachments []syncdomain.Attachment +} + +func (fake *fakeAttachments) ReadAttachment(_ string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { + for _, attachment := range fake.attachments { + if attachment.Kind == kind && attachment.Key() == key { + return attachment, nil + } + } + return syncdomain.Attachment{}, errors.New(`{"code":"not_found","statusCode":404}`) +} + +func (fake *fakeAttachments) SearchAttachments( + string, syncdomain.AttachmentKind, string, int, int, +) (syncapi.Page[syncdomain.Attachment], error) { + return syncapi.Page[syncdomain.Attachment]{Items: fake.attachments, TotalCount: len(fake.attachments)}, nil +} diff --git a/internal/sync/bootstrap/bootstrap.go b/internal/sync/bootstrap/bootstrap.go index 15c766041..6c0063cf8 100644 --- a/internal/sync/bootstrap/bootstrap.go +++ b/internal/sync/bootstrap/bootstrap.go @@ -1,5 +1,5 @@ -// Package bootstrap adds LaunchDarkly variations to the local workspace. The -// first add creates the .launchdarkly directory. +// Package bootstrap adds LaunchDarkly variations, tools, and skills to the +// local workspace. The first add creates the .launchdarkly directory. package bootstrap import ( @@ -27,13 +27,17 @@ type Catalog interface { Config(projectKey, configKey string) (syncapi.Config, error) } -// AttachmentReader reads the tools and skills that a variation uses. +// AttachmentReader reads the tools and skills that a variation uses, and +// searches the tools or skills that the user adds. type AttachmentReader interface { + syncinteractive.AttachmentSearcher ReadAttachment(projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) } // Options are the dependencies and the input of one add. type Options struct { + // Kind is the kind of resource to add. The empty kind is a variation. + Kind syncdomain.Kind Catalog Catalog Attachments AttachmentReader Store synclocal.Store @@ -48,13 +52,18 @@ type Options struct { // ProjectKey and ConfigKey skip the project and the config questions. ProjectKey string ConfigKey string - // Selections are the variations to add. If it is empty, Run asks the user. + // Selections are the resources to add. If it is empty, Run asks the user. Selections []syncdomain.ResourceID NoInput bool } -// Run writes the files of the selected variations and records their baseline. +// Run writes the files of the selected resources and records their baseline. func Run(options Options) error { + switch options.Kind { + case syncdomain.KindTool, syncdomain.KindSkill: + return addAttachments(options) + } + var files []synclocal.VariationFile var err error if len(options.Selections) != 0 { @@ -287,7 +296,7 @@ func finishSelection(options Options, files []synclocal.VariationFile) error { return errors.Join(err, options.Store.RollbackCreation(creation)) } - writeSummary(options.Output, options.Initial, len(creation.VariationPaths)) + writeSummary(options.Output, options.Initial, len(creation.VariationPaths), "variation") return nil } @@ -339,13 +348,13 @@ func writeNoChangeSummary(output io.Writer, dryRun bool) { _ = syncconsole.New(output).Line(message) } -// writeSummary reports how many variation files the add created. -func writeSummary(output io.Writer, initial bool, count int) { +// writeSummary reports how many files of kind the add created. +func writeSummary(output io.Writer, initial bool, count int, kind string) { verb := "Added" if initial { verb = "Bootstrapped" } - noun := "variation file" + noun := kind + " file" if count != 1 { noun += "s" } diff --git a/internal/sync/bootstrap/bootstrap_test.go b/internal/sync/bootstrap/bootstrap_test.go index b0d4b5aaa..8851b5177 100644 --- a/internal/sync/bootstrap/bootstrap_test.go +++ b/internal/sync/bootstrap/bootstrap_test.go @@ -423,6 +423,7 @@ func TestWriteSummary(t *testing.T) { &output, true, 2, + "variation", ) assert.Equal( t, diff --git a/internal/sync/interactive/catalog.go b/internal/sync/interactive/catalog.go index d2076debc..373294c0d 100644 --- a/internal/sync/interactive/catalog.go +++ b/internal/sync/interactive/catalog.go @@ -18,6 +18,15 @@ type ConfigSearcher interface { SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error) } +// AttachmentSearcher searches the tools or the skills in a project. +type AttachmentSearcher interface { + SearchAttachments( + projectKey string, kind syncdomain.AttachmentKind, query string, limit, offset int, + ) (syncapi.Page[syncdomain.Attachment], error) +} + +const attachmentPageSize = 25 + // SelectProject asks the user to choose a project. The bool result is true // when the user cancels. func SelectProject(input io.Reader, output io.Writer, catalog ProjectSearcher) (syncapi.Project, bool, error) { @@ -68,3 +77,45 @@ func SelectConfig( }, }) } + +// SelectAttachment asks the user to search for a tool or a skill and choose +// it. The bool result is true when the user cancels. +func SelectAttachment( + input io.Reader, + output io.Writer, + searcher AttachmentSearcher, + projectKey string, + kind syncdomain.AttachmentKind, +) (syncdomain.Attachment, bool, error) { + name := string(kind) + return SearchSelect(SearchOptions[syncdomain.Attachment]{ + Input: input, + Output: output, + SearchTitle: "Search " + name + "s", + SearchPlaceholder: "Name or key", + SelectTitle: "Choose a " + name, + ItemName: name + "s", + PageSize: attachmentPageSize, + Fetch: func(query string, limit, offset int) ([]syncdomain.Attachment, int, error) { + page, err := searcher.SearchAttachments(projectKey, kind, query, limit, offset) + return page.Items, page.TotalCount, err + }, + Choice: attachmentChoice, + }) +} + +// attachmentChoice shows the name of a skill, or the key of a tool, which has no name. +func attachmentChoice(attachment syncdomain.Attachment) Choice[syncdomain.Attachment] { + if attachment.Skill != nil { + return Choice[syncdomain.Attachment]{ + Title: attachment.Skill.Name, + Description: fmt.Sprintf("Key: %s · Latest version: %d", attachment.Key(), attachment.Version), + Value: attachment, + } + } + return Choice[syncdomain.Attachment]{ + Title: "Key: " + attachment.Key(), + Description: fmt.Sprintf("Latest version: %d", attachment.Version), + Value: attachment, + } +} diff --git a/internal/sync/local/attachment.go b/internal/sync/local/attachment.go index ea3b57f53..96ae98c03 100644 --- a/internal/sync/local/attachment.go +++ b/internal/sync/local/attachment.go @@ -204,6 +204,16 @@ func renderAttachmentFiles(files []VariationFile) ([]RenderedFile, error) { return rendered, nil } +// RenderAttachment renders the file of one tool or skill without writing it. +func RenderAttachment(attachment syncdomain.SyncedAttachment) (RenderedFile, error) { + filePath, err := attachmentPath(attachment.ProjectKey, attachment.Attachment.Kind, attachment.Attachment.Key()) + if err != nil { + return RenderedFile{}, err + } + content, err := renderAttachment(attachment.Attachment) + return RenderedFile{Path: filePath, Content: content}, err +} + func renderAttachment(attachment syncdomain.Attachment) ([]byte, error) { switch { case attachment.Kind == syncdomain.AttachmentTool && attachment.Tool != nil: diff --git a/internal/sync/local/layout.go b/internal/sync/local/layout.go index 29506dc6b..b5e7ed5eb 100644 --- a/internal/sync/local/layout.go +++ b/internal/sync/local/layout.go @@ -62,6 +62,16 @@ func ParseManagedPath(file string) (syncdomain.ResourceID, bool) { return id, true } +// AttachmentPath returns the path of a tool or skill file relative to the +// repository root, in the form that ParseManagedPath reads. +func AttachmentPath(id syncdomain.ResourceID) (string, error) { + relativePath, err := attachmentPath(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey) + if err != nil { + return "", err + } + return path.Join(syncdomain.RootDir, relativePath), nil +} + // variationPath returns the path of a variation file relative to the managed // directory. It makes sure that each key is a safe path segment. func variationPath(projectKey, configKey, variationKey string) (string, error) { diff --git a/internal/sync/local/replace.go b/internal/sync/local/replace.go index b10f19105..1556d1b8e 100644 --- a/internal/sync/local/replace.go +++ b/internal/sync/local/replace.go @@ -57,15 +57,11 @@ func (store Store) ReplaceVariations(replacements []VariationReplacement) ([]str func (store Store) ReplaceAttachments(attachments []syncdomain.SyncedAttachment) ([]string, error) { rendered := make([]RenderedFile, 0, len(attachments)) for _, attachment := range attachments { - filePath, err := attachmentPath(attachment.ProjectKey, attachment.Attachment.Kind, attachment.Attachment.Key()) + file, err := RenderAttachment(attachment) if err != nil { return nil, err } - content, err := renderAttachment(attachment.Attachment) - if err != nil { - return nil, err - } - rendered = append(rendered, RenderedFile{Path: filePath, Content: content}) + rendered = append(rendered, file) } files, err := store.prepareAttachmentFiles(rendered) if err != nil { diff --git a/internal/sync/local/store.go b/internal/sync/local/store.go index 8b5182172..2cfa10492 100644 --- a/internal/sync/local/store.go +++ b/internal/sync/local/store.go @@ -83,14 +83,29 @@ func (store Store) VariationExists(projectKey, configKey, variationKey string) ( if err != nil { return false, err } - _, err = os.Stat(store.absolute(relativePath)) + return store.fileExists(relativePath, "variation "+variationKey) +} + +// AttachmentExists reports whether the file of a tool or a skill exists. +func (store Store) AttachmentExists(id syncdomain.ResourceID) (bool, error) { + relativePath, err := attachmentPath(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey) + if err != nil { + return false, err + } + return store.fileExists(relativePath, id.Label()) +} + +// fileExists reports whether a managed file exists. label names the file in +// an error. +func (store Store) fileExists(relativePath, label string) (bool, error) { + _, err := os.Stat(store.absolute(relativePath)) switch { case err == nil: return true, nil case errors.Is(err, os.ErrNotExist): return false, nil default: - return false, fmt.Errorf("inspect variation %s: %w", variationKey, err) + return false, fmt.Errorf("inspect %s: %w", label, err) } } diff --git a/internal/sync/prompt/attach.go b/internal/sync/prompt/attach.go index 443b55726..491183e62 100644 --- a/internal/sync/prompt/attach.go +++ b/internal/sync/prompt/attach.go @@ -12,8 +12,6 @@ import ( synclocal "github.com/launchdarkly/ldcli/internal/sync/local" ) -const attachmentSearchPageSize = 25 - var errAttachmentCanceled = errors.New("attachment selection canceled") // attachOptions are the input of one attach. An empty field means that the @@ -177,20 +175,7 @@ func selectAttachment(client syncapi.Client, projectKey string, options attachOp return syncdomain.Attachment{}, fmt.Errorf("%s key is required without interactive input", options.Kind) } - kind := string(options.Kind) - attachment, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncdomain.Attachment]{ - Input: options.Input, - Output: options.Output, - SearchTitle: "Search " + kind + "s", - SearchPlaceholder: "Name or key", - SelectTitle: "Choose a " + kind, - ItemName: kind + "s", - PageSize: attachmentSearchPageSize, - Fetch: func(query string, limit, offset int) ([]syncdomain.Attachment, int, error) { - return searchAttachmentPage(client, projectKey, options.Kind, query, limit, offset) - }, - Choice: attachmentChoice, - }) + attachment, canceled, err := syncinteractive.SelectAttachment(options.Input, options.Output, client, projectKey, options.Kind) if err != nil { return syncdomain.Attachment{}, err } @@ -199,30 +184,3 @@ func selectAttachment(client syncapi.Client, projectKey string, options attachOp } return client.ReadAttachment(projectKey, options.Kind, attachment.Key()) } - -func searchAttachmentPage( - client syncapi.Client, - projectKey string, - kind syncdomain.AttachmentKind, - query string, - limit, offset int, -) ([]syncdomain.Attachment, int, error) { - page, err := client.SearchAttachments(projectKey, kind, query, limit, offset) - return page.Items, page.TotalCount, err -} - -// attachmentChoice shows the name of a skill, or the key of a tool, which has no name. -func attachmentChoice(attachment syncdomain.Attachment) syncinteractive.Choice[syncdomain.Attachment] { - if attachment.Skill != nil { - return syncinteractive.Choice[syncdomain.Attachment]{ - Title: attachment.Skill.Name, - Description: fmt.Sprintf("Key: %s · Latest version: %d", attachment.Key(), attachment.Version), - Value: attachment, - } - } - return syncinteractive.Choice[syncdomain.Attachment]{ - Title: "Key: " + attachment.Key(), - Description: fmt.Sprintf("Latest version: %d", attachment.Version), - Value: attachment, - } -} diff --git a/internal/sync/prompt/runner_test.go b/internal/sync/prompt/runner_test.go index 0179931cf..f5adec066 100644 --- a/internal/sync/prompt/runner_test.go +++ b/internal/sync/prompt/runner_test.go @@ -312,23 +312,23 @@ func TestAttachToVariationPreservesStandaloneAttachmentEdits(t *testing.T) { func TestEmptyAttachmentSearchReturnsNoResults(t *testing.T) { client := syncapi.NewClient(emptyAttachmentClient{}, "token", "https://example.com") - attachments, totalCount, err := searchAttachmentPage(client, "project", syncdomain.AttachmentTool, "missing", 25, 0) + page, err := client.SearchAttachments("project", syncdomain.AttachmentTool, "missing", 25, 0) require.NoError(t, err) - assert.Empty(t, attachments) - assert.Zero(t, totalCount) + assert.Empty(t, page.Items) + assert.Zero(t, page.TotalCount) } func TestToolSearchPageReturnsTrueLatestVersion(t *testing.T) { transport := &toolSearchClient{} client := syncapi.NewClient(transport, "token", "https://example.com") - attachments, totalCount, err := searchAttachmentPage(client, "project", syncdomain.AttachmentTool, "old description", 25, 0) + page, err := client.SearchAttachments("project", syncdomain.AttachmentTool, "old description", 25, 0) require.NoError(t, err) - require.Len(t, attachments, 1) - assert.Equal(t, 3, attachments[0].Version) - assert.Equal(t, 1, totalCount) + require.Len(t, page.Items, 1) + assert.Equal(t, 3, page.Items[0].Version) + assert.Equal(t, 1, page.TotalCount) assert.Equal(t, 2, transport.requests) }