-
Notifications
You must be signed in to change notification settings - Fork 14
feat(sync): orchestrate prompt synchronization #820
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| package prompt | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/sha256" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "time" | ||
|
|
||
| syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" | ||
| syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" | ||
| ) | ||
|
|
||
| type conflictResolution string | ||
|
|
||
| const ( | ||
| useLaunchDarkly conflictResolution = "launchdarkly" | ||
| useLocal conflictResolution = "local" | ||
| abortConflict conflictResolution = "abort" | ||
| ) | ||
|
|
||
| type conflictResolutionResult struct { | ||
| resolutions map[ResourceID]conflictResolution | ||
| aborted bool | ||
| sourcesChanged bool | ||
| } | ||
|
|
||
| type conflictChoice struct { | ||
| resolution conflictResolution | ||
| aborted bool | ||
| sourcesChanged bool | ||
| } | ||
|
|
||
| // resolveConflicts shows each conflict and asks which side should win. | ||
| func resolveConflicts( | ||
| options Options, | ||
| plan Plan, | ||
| reader io.Reader, | ||
| interactive bool, | ||
| watched *watchedSources, | ||
| ) (conflictResolutionResult, error) { | ||
| var conflicts []PlannedResource | ||
| for _, resource := range plan.Resources { | ||
| if resource.Action == ActionConflict { | ||
| conflicts = append(conflicts, resource) | ||
| } | ||
| } | ||
| if len(conflicts) == 0 { | ||
| return conflictResolutionResult{}, nil | ||
| } | ||
| if !interactive { | ||
| return conflictResolutionResult{}, fmt.Errorf( | ||
| "interactive conflict resolution requires a terminal; rerun in a terminal or resolve the conflict manually", | ||
| ) | ||
| } | ||
|
|
||
| result := conflictResolutionResult{resolutions: make(map[ResourceID]conflictResolution, len(conflicts))} | ||
| for _, resource := range conflicts { | ||
| conflict := Plan{Resources: []PlannedResource{resource}} | ||
| if err := writePlanReview(options.ErrorOutput, "plaintext", conflict, terminalWidth(options.ErrorOutput)); err != nil { | ||
| return conflictResolutionResult{}, err | ||
| } | ||
|
|
||
| choice, err := readConflictChoice(options, reader, watched) | ||
| if err != nil { | ||
| return conflictResolutionResult{}, err | ||
| } | ||
| if choice.sourcesChanged { | ||
| result.sourcesChanged = true | ||
| return result, nil | ||
| } | ||
| if choice.aborted { | ||
| result.aborted = true | ||
| return result, nil | ||
| } | ||
| result.resolutions[resource.ID] = choice.resolution | ||
| } | ||
| return result, nil | ||
| } | ||
|
|
||
| // readConflictChoice waits for a regular terminal choice or a watch-aware | ||
| // choice that can be interrupted by another source change. | ||
| func readConflictChoice(options Options, reader io.Reader, watched *watchedSources) (conflictChoice, error) { | ||
| if watched != nil { | ||
| return promptWatchedConflictResolution(options.Context, options.Input, options.ErrorOutput, *watched) | ||
| } | ||
| return promptConflictResolution(context.Background(), reader, options.ErrorOutput) | ||
| } | ||
|
|
||
| // promptConflictResolution asks which side should win one conflict. | ||
| func promptConflictResolution( | ||
| ctx context.Context, | ||
| input io.Reader, | ||
| output io.Writer, | ||
| ) (conflictChoice, error) { | ||
| resolution, canceled, err := syncinteractive.SelectContext( | ||
| ctx, | ||
| input, | ||
| output, | ||
| "Choose how to resolve this conflict", | ||
| []syncinteractive.Choice[conflictResolution]{ | ||
| {Title: "Use LaunchDarkly", Value: useLaunchDarkly}, | ||
| {Title: "Use local", Value: useLocal}, | ||
| {Title: "Abort and resolve manually", Value: abortConflict}, | ||
| }, | ||
| ) | ||
| if err != nil { | ||
| return conflictChoice{}, err | ||
| } | ||
| choice := conflictChoice{ | ||
| resolution: resolution, | ||
| aborted: canceled || resolution == abortConflict, | ||
| } | ||
| writeConflictChoice(output, choice) | ||
| return choice, nil | ||
| } | ||
|
|
||
| // promptWatchedConflictResolution refreshes the plan if a source changes | ||
| // while the conflict selector is open. | ||
| func promptWatchedConflictResolution( | ||
| ctx context.Context, | ||
| input io.Reader, | ||
| output io.Writer, | ||
| watched watchedSources, | ||
| ) (conflictChoice, error) { | ||
| promptContext, cancel := context.WithCancel(ctx) | ||
| defer cancel() | ||
|
|
||
| changeResult := make(chan error, 1) | ||
| // Race the form against filesystem changes. Canceling the shared child | ||
| // context guarantees exactly one path wins and the other exits promptly. | ||
| go func() { | ||
| changeResult <- watched.WaitForChange(promptContext) | ||
| cancel() | ||
| }() | ||
|
|
||
| choice, promptErr := promptConflictResolution(promptContext, input, output) | ||
| cancel() | ||
| changeErr := <-changeResult | ||
| if changeErr == nil { | ||
| _ = syncconsole.New(output).Line("\nA watched file changed; refreshing...") | ||
| return conflictChoice{sourcesChanged: true}, nil | ||
| } | ||
| if !errors.Is(changeErr, context.Canceled) { | ||
| return conflictChoice{}, changeErr | ||
| } | ||
| if promptErr == nil && choice.aborted { | ||
| return conflictChoice{}, context.Canceled | ||
| } | ||
| if promptErr != nil { | ||
| if ctx.Err() != nil { | ||
| return conflictChoice{}, context.Canceled | ||
| } | ||
| return conflictChoice{}, promptErr | ||
| } | ||
| return choice, nil | ||
| } | ||
|
|
||
| // writeConflictChoice confirms the selected resolution after the form exits. | ||
| func writeConflictChoice(output io.Writer, choice conflictChoice) { | ||
| console := syncconsole.New(output) | ||
| switch { | ||
| case choice.aborted: | ||
| _ = console.Line("Sync canceled; conflict left unresolved.") | ||
| case choice.resolution == useLaunchDarkly: | ||
| _ = console.Line("Using LaunchDarkly.") | ||
| case choice.resolution == useLocal: | ||
| _ = console.Line("Using local.") | ||
| } | ||
| } | ||
|
|
||
| type watchedSources struct { | ||
| watcher *sourceWatcher | ||
| snapshot [sha256.Size]byte | ||
| debounce time.Duration | ||
| } | ||
|
|
||
| // WaitForChange waits until the watched source content differs from the state | ||
| // used to build the current plan. | ||
| func (watched watchedSources) WaitForChange(ctx context.Context) error { | ||
| for { | ||
| if err := watched.watcher.WaitForChange(ctx, watched.debounce); err != nil { | ||
| return err | ||
| } | ||
| current, err := sourceSnapshot(watched.watcher.root) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if current != watched.snapshot { | ||
| return nil | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // applyConflictResolutions replaces conflict actions with the selected direction. | ||
| func applyConflictResolutions(plan Plan, resolutions map[ResourceID]conflictResolution) Plan { | ||
| resolved := Plan{Resources: append([]PlannedResource(nil), plan.Resources...)} | ||
| for index := range resolved.Resources { | ||
| resource := &resolved.Resources[index] | ||
| resolution, ok := resolutions[resource.ID] | ||
| if !ok || resource.Action != ActionConflict { | ||
| continue | ||
| } | ||
| resource.Action = resolvedConflictAction(*resource, resolution) | ||
| } | ||
| return resolved | ||
| } | ||
|
|
||
| // resolvedConflictAction returns the operation needed for the chosen side to win. | ||
| func resolvedConflictAction(resource PlannedResource, resolution conflictResolution) Action { | ||
| switch resolution { | ||
| case useLaunchDarkly: | ||
| if resource.Server == nil { | ||
| return ActionDeleteLocal | ||
| } | ||
| return ActionUpdateLocal | ||
| case useLocal: | ||
| if resource.Local == nil { | ||
| return ActionArchiveServer | ||
| } | ||
| if resource.Server == nil { | ||
| return ActionCreateServer | ||
| } | ||
| return ActionUpdateServer | ||
| default: | ||
| return ActionConflict | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.