Skip to content
Open
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
2 changes: 1 addition & 1 deletion cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func newAddCmd(client resources.Client) *cobra.Command {
return nil, err
}
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
return syncprompt.AddAction{Variations: variations, Filter: targetFilter(cmd), DryRun: dryRun}, nil
return syncprompt.AddAction{Resources: variations, Filter: targetFilter(cmd), DryRun: dryRun}, nil
}),
}
addTargetFlags(cmd, questionFlagHelp)
Expand Down
287 changes: 268 additions & 19 deletions internal/sync/prompt/acceptance_test.go

Large diffs are not rendered by default.

64 changes: 36 additions & 28 deletions internal/sync/prompt/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,26 @@ import (
syncreference "github.com/launchdarkly/ldcli/internal/sync/reference"
)

// CommandAction is one prompt sync operation.
// CommandAction is one sync operation. scope is the scope of the command.
type CommandAction interface {
validate() error
validate(scope syncdomain.Scope) error
}

// SyncAction reconciles the workspace once, or each time a watched file
// changes. Scope is the resource kinds that the sync plans, and Filter limits
// the sync to the resources that it names.
// changes. Filter limits the sync to the resources that it names.
type SyncAction struct {
Watch bool
DryRun bool
Scope syncdomain.Scope
Filter syncdomain.Filter
}

// AddAction writes selected LaunchDarkly variations as local files. The
// variations come from positional selectors or from the target flags in
// Filter, which also skip the project and config questions.
// AddAction writes selected LaunchDarkly resources of the kind of the scope
// as local files. The resources come from positional selectors or from the
// target flags in Filter, which also skip the project and config questions.
type AddAction struct {
Variations []syncdomain.ResourceID
Filter syncdomain.Filter
DryRun bool
Resources []syncdomain.ResourceID
Filter syncdomain.Filter
DryRun bool
}

// AttachAction adds a tool or a skill to a local variation, and then syncs.
Expand Down Expand Up @@ -62,22 +60,22 @@ type LinkAction struct {
Target *synclink.Target
}

func (action SyncAction) validate() error {
func (action SyncAction) validate(scope syncdomain.Scope) error {
if action.Watch && action.DryRun {
return errors.New("watch does not support --dry-run")
}
return action.Filter.Validate(action.Scope.KeyKind())
return action.Filter.Validate(scope.KeyKind())
}

func (action AddAction) validate() error {
return validateSelections(action.Variations, action.Filter)
func (action AddAction) validate(scope syncdomain.Scope) error {
return validateSelections(action.Resources, action.Filter, scope.KeyKind())
}

func (action DetachAction) validate() error {
return validateSelections(action.Resources, action.Filter)
func (action DetachAction) validate(scope syncdomain.Scope) error {
return validateSelections(action.Resources, action.Filter, scope.KeyKind())
}

func (action AttachAction) validate() error {
func (action AttachAction) validate(syncdomain.Scope) error {
if !action.Kind.Valid() {
return errors.New("attachment kind must be tool or skill")
}
Expand All @@ -87,7 +85,7 @@ func (action AttachAction) validate() error {
return validateProjectFlag(action.ProjectKey, action.Target)
}

func (action LinkAction) validate() error {
func (action LinkAction) validate(syncdomain.Scope) error {
switch {
case action.File == "":
return errors.New("linked file is required")
Expand All @@ -106,13 +104,19 @@ func (action LinkAction) validate() error {
return syncreference.ValidateFormat(action.Format)
}

// validateSelections rejects variations that come both from positional
// selectors and from target flags.
func validateSelections(selectors []syncdomain.ResourceID, filter syncdomain.Filter) error {
if err := filter.Validate(syncdomain.KindVariation); err != nil {
Comment thread
cursor[bot] marked this conversation as resolved.
// validateSelections rejects positional selectors outside the prompts scope,
// and resources that come both from positional selectors and from target
// flags. kind is the kind that --key names.
func validateSelections(selectors []syncdomain.ResourceID, filter syncdomain.Filter, kind syncdomain.Kind) error {
if err := filter.Validate(kind); err != nil {
return err
}
if len(selectors) != 0 && !filter.IsZero() {
switch {
case len(selectors) == 0:
return nil
case kind != syncdomain.KindVariation:
return fmt.Errorf("only variations have positional selectors. Pass %ss as --project and --key.", kindNoun(kind))
case !filter.IsZero():
return errors.New("pass variations as --project, --config-key, and --key, " +
"or as project-key/config-key/variation-key arguments, not both")
}
Expand All @@ -131,12 +135,13 @@ func validateProjectFlag(projectKey string, target *syncdomain.ResourceID) error
return nil
}

// selections returns the variations that positional selectors or --key name.
func selections(selectors []syncdomain.ResourceID, filter syncdomain.Filter) []syncdomain.ResourceID {
// selections returns the resources that positional selectors or --key name.
// kind is the kind that --key names.
func selections(selectors []syncdomain.ResourceID, filter syncdomain.Filter, kind syncdomain.Kind) []syncdomain.ResourceID {
if len(selectors) != 0 {
return selectors
}
return filter.IDs(syncdomain.KindVariation)
return filter.IDs(kind)
}

// Options are the command input and the streams of one prompt sync command.
Expand All @@ -145,6 +150,9 @@ type Options struct {
AccessToken string
BaseURI string
OutputKind string
// Scope is the resource kinds that the command syncs, adds, or detaches.
// The empty scope is prompt variations.
Scope syncdomain.Scope
// Action is the operation to run. A nil action is a SyncAction.
Action CommandAction
ConflictPolicy ConflictPolicy
Expand All @@ -166,7 +174,7 @@ func validateOptions(options Options) error {
if options.Action == nil {
return nil
}
return options.Action.validate()
return options.Action.validate(options.Scope)
}

// withDefaults returns the options with a context and an action.
Expand Down
16 changes: 12 additions & 4 deletions internal/sync/prompt/conflict.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ type conflictChoice struct {
sourcesChanged bool
}

// missingConflictChoiceError names the flags that choose a side without a
// terminal. Only sync prompts has --resolve.
func missingConflictChoiceError(scope syncdomain.Scope) error {
if scope == "" || scope == syncdomain.ScopePrompts {
return errors.New("conflict resolution requires --conflict or --resolve without interactive input")
}
return errors.New("conflict resolution requires --conflict without interactive input")
}

// resolveConflicts shows each conflict and gets a choice from the policy or
// from the user. In watch mode, a file change during the choice stops it.
func resolveConflicts(
Expand Down Expand Up @@ -128,9 +137,7 @@ func resolveConflicts(
return conflictResolutionResult{}, errConflictAborted
}
case !interactive:
return conflictResolutionResult{}, errors.New(
"conflict resolution requires --conflict or --resolve without interactive input",
)
return conflictResolutionResult{}, missingConflictChoiceError(options.Scope)
case watched != nil:
choice, err = promptWatchedConflictResolution(options.Context, options.Input, options.ErrorOutput, *watched)
default:
Expand Down Expand Up @@ -308,7 +315,8 @@ func writeConflictChoice(output io.Writer, choice conflictChoice) {
// resource has the action for its chosen side. The original plan stays as
// the reviewed record.
func applyConflictResolutions(plan Plan, resolutions map[ResourceID]ConflictResolution) Plan {
resolved := Plan{Resources: make([]PlannedResource, 0, len(plan.Resources))}
resolved := plan
resolved.Resources = make([]PlannedResource, 0, len(plan.Resources))
for _, resource := range plan.Resources {
if !chosenSideUses(resource, resolutions) {
continue
Expand Down
52 changes: 50 additions & 2 deletions internal/sync/prompt/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func checkOutputKind(outputKind string) (string, error) {
func writePlanReview(out io.Writer, outputKind string, plan Plan, width int, showDiffs bool) error {
console := syncconsole.New(out)
if len(plan.Resources) == 0 {
_ = console.Line("No prompt variations are synced in this repository. To start, run ldcli sync prompts add.")
_ = console.Line(emptyPlanMessage(plan.Scope))
return nil
}
_ = console.Line(planSummary(plan))
Expand Down Expand Up @@ -198,8 +198,21 @@ func writePlanReview(out io.Writer, outputKind string, plan Plan, width int, sho
return nil
}

// emptyPlanMessage tells the user how to start syncing the kinds of a scope.
func emptyPlanMessage(scope syncdomain.Scope) string {
switch scope {
case syncdomain.ScopeAll:
return nothingToSync
case syncdomain.ScopeTools, syncdomain.ScopeSkills:
return fmt.Sprintf("No %s are synced in this repository. To start, run ldcli sync %s add.", scope, scope)
default:
return "No prompt variations are synced in this repository. To start, run ldcli sync prompts add."
}
}

// writePlanNotes writes one line for each resource that another working copy
// synced after this working copy wrote its sync.lock file.
// synced after this working copy wrote its sync.lock file, and one line for
// the standalone tool and skill files that a prompts plan leaves out.
func writePlanNotes(out io.Writer, plan Plan) {
console := syncconsole.New(out)
for _, resource := range plan.Resources {
Expand All @@ -209,6 +222,41 @@ func writePlanNotes(out io.Writer, plan Plan) {
)
}
}

var counts []string
total := 0
for _, kind := range []syncdomain.Kind{syncdomain.KindSkill, syncdomain.KindTool} {
if count := plan.standalone[kind]; count != 0 {
counts = append(counts, countOf(count, "standalone "+kindNoun(kind)))
total += count
}
}
if total != 0 {
verb := "are"
if total == 1 {
verb = "is"
}
_ = console.Printf("Note: %s %s not in this plan. Run ldcli sync all to include them.\n", strings.Join(counts, " and "), verb)
}
}

// writeRepinNotes writes one line for each local variation that uses a tool or
// a skill that a sync of tools or skills pushed. That sync does not write
// variations, so the variation repins on the next sync of prompts.
func writeRepinNotes(out io.Writer, plan Plan, outcomes []ResourceOutcome) {
if plan.Scope.Includes(syncdomain.KindVariation) {
return
}
console := syncconsole.New(out)
for index, outcome := range outcomes {
if outcome.Status != OutcomeSucceeded || !outcome.Action.changesServer() {
continue
}
for _, variation := range plan.Resources[index].usedBy {
_ = console.Printf("Note: Prompt %s uses %s %s. It repins on the next ldcli sync prompts or ldcli sync all.\n",
variation, outcome.ID.Kind, outcome.ID.LookupKey)
}
}
}

// planSummary describes the plan in one line, for example
Expand Down
14 changes: 14 additions & 0 deletions internal/sync/prompt/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,20 @@ func TestWritePlanReviewSummarizesPlansWithoutChanges(t *testing.T) {
assert.Equal(t, "No changes. 2 in sync.\n", output.String())
}

func TestWritePlanReviewNamesTheAddOfAnEmptyScope(t *testing.T) {
for scope, want := range map[syncdomain.Scope]string{
syncdomain.ScopePrompts: "No prompt variations are synced in this repository. To start, run ldcli sync prompts add.\n",
syncdomain.ScopeTools: "No tools are synced in this repository. To start, run ldcli sync tools add.\n",
syncdomain.ScopeSkills: "No skills are synced in this repository. To start, run ldcli sync skills add.\n",
syncdomain.ScopeAll: "Nothing to sync in this repository. " +
"To start, run ldcli sync prompts add, ldcli sync tools add, or ldcli sync skills add.\n",
} {
var output bytes.Buffer
require.NoError(t, writePlanReview(&output, "plaintext", Plan{Scope: scope}, 0, false))
assert.Equal(t, want, output.String(), "scope %s", scope)
}
}

func TestPlanReasonNamesTheChange(t *testing.T) {
variation := testVariation("Support")
tests := map[string]struct {
Expand Down
9 changes: 9 additions & 0 deletions internal/sync/prompt/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ type PlannedResource struct {
// conflictUses are the conflicted variations that newly use the tool or
// skill of this row on one side, when no push or pull needs the row.
conflictUses []conflictUse
// usedBy are the local variations that use the tool or skill of this row.
usedBy []ResourceID
// restoreRef is the link that a restored variation file keeps.
restoreRef *syncdomain.Reference
}
Expand Down Expand Up @@ -115,6 +117,13 @@ type conflictUse struct {
// Plan is the sync decision for each resource, in identity order.
type Plan struct {
Resources []PlannedResource
// Scope is the resource kinds that the plan covers. The empty scope is
// prompt variations.
Scope syncdomain.Scope
// standalone counts the tool and skill files, by kind, that no local
// variation uses. Only a prompts plan counts them, because it leaves them
// out.
standalone map[syncdomain.Kind]int
}

// BuildPlan compares the baseline with the local and the server state. The
Expand Down
26 changes: 19 additions & 7 deletions internal/sync/prompt/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,17 @@ func (runner Runner) Run(options Options) error {
return runner.runWorkspaceSync(options, workspace, nil)
}

// nothingToSync tells the user how to start a sync of every kind.
const nothingToSync = "Nothing to sync in this repository. " +
"To start, run ldcli sync prompts add, ldcli sync tools add, or ldcli sync skills add."

// errNothingToSync is the error of a sync of every kind in a repository
// without sync files. A sync of one kind starts its add instead.
var errNothingToSync = errors.New(nothingToSync)

// runSync syncs once, or watches the workspace. A workspace without any sync
// files starts with an add in the filter project, so that the user can choose
// the first variations.
// files starts with an add of the scope's kind in the filter project, so that
// the user can choose the first resources.
func (runner Runner) runSync(options Options, workspace syncWorkspace, action SyncAction) error {
exists, err := workspace.local.Exists()
if err != nil {
Expand All @@ -112,8 +120,10 @@ func (runner Runner) runSync(options Options, workspace syncWorkspace, action Sy
return err
}
if !exists && len(synced) == 0 {
add := AddAction{Filter: action.Filter, DryRun: action.DryRun}
if err := runner.bootstrap(runner.bootstrapOptions(options, workspace, true, add)); err != nil {
if options.Scope == syncdomain.ScopeAll {
return errNothingToSync
}
if err := runner.runAdd(options, workspace, AddAction{Filter: action.Filter, DryRun: action.DryRun}); err != nil {
return err
}
if !action.Watch {
Expand Down Expand Up @@ -155,9 +165,9 @@ func (runner Runner) runDetach(options Options, workspace syncWorkspace, action
ProjectKeys: projectKeys(synced, syncdomain.Filter{}),
Input: options.Input,
Output: options.Output,
Kind: syncdomain.KindVariation,
Kind: options.Scope.KeyKind(),
Filter: action.Filter,
Selections: selections(action.Resources, action.Filter),
Selections: selections(action.Resources, action.Filter, options.Scope.KeyKind()),
NoInput: options.NoInput,
Archive: action.Archive,
Archiver: runner.api(options),
Expand Down Expand Up @@ -222,7 +232,9 @@ func (runner Runner) runAttach(options Options, workspace syncWorkspace, action

func (runner Runner) bootstrapOptions(options Options, workspace syncWorkspace, initial bool, action AddAction) syncbootstrap.Options {
api := runner.api(options)
kind := options.Scope.KeyKind()
return syncbootstrap.Options{
Kind: kind,
Catalog: api,
Attachments: api,
Store: workspace.local,
Expand All @@ -233,7 +245,7 @@ func (runner Runner) bootstrapOptions(options Options, workspace syncWorkspace,
DryRun: action.DryRun,
ProjectKey: action.Filter.Project,
ConfigKey: action.Filter.ConfigKey,
Selections: selections(action.Variations, action.Filter),
Selections: selections(action.Resources, action.Filter, kind),
NoInput: options.NoInput,
Diff: options.Diff,
}
Expand Down
Loading
Loading