From 6f963c76609b139d4cb2bb21b0c465855058d1ef Mon Sep 17 00:00:00 2001 From: pablu Date: Mon, 3 Aug 2026 13:31:45 +0200 Subject: [PATCH] fix: resolve pending and jumping --- README.md | 13 +++-- health.go | 11 ++++ health_test.go | 12 +++- mutation_queue.go | 125 ++++++++++++++++++++++++++++++----------- mutation_queue_test.go | 89 +++++++++++++++++++++++++++-- pr_editor.go | 2 +- tui.go | 82 +++++++++++++++++++++------ tui_test.go | 54 ++++++++++++++++++ version.go | 2 +- 9 files changed, 330 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 45376c5..13b890c 100644 --- a/README.md +++ b/README.md @@ -523,16 +523,19 @@ Cached data is labelled when first shown. A normal refresh does not repeatedly reintroduce the cached header. Read state and recoverable drafts live beside the configuration file as -`state.json` and `drafts.json`. Confirmed reversible GitHub writes are kept in -`mutation-queue.json` until GitHub confirms them. Experimental AI data defaults +`state.json` and `drafts.json`. Reversible GitHub writes are kept in +`mutation-queue.json` until a live refresh verifies them. Experimental AI data defaults to the `ai` directory beside the configuration. These files are versioned and written atomically; sensitive user-authored state uses restrictive permissions. Cached permission gates are treated as the last known truth while offline: actions granted by the snapshot can be queued, while actions denied by it stay -disabled. Pending replies and projected PR or thread changes are labelled in -the UI without being written into the read cache. Replay preserves global -enqueue order, including across repositories. +disabled. Queued replies and projected PR or thread changes are displayed +optimistically as successful without being written into the read cache. A +pending marker appears only after a live refresh cannot verify the change or a +definite rejection needs attention. Retryable transport failures remain +optimistic and are recorded in Health. Replay preserves global enqueue order, +including across repositories. If GitHub permissions or the target changed, replay pauses before the first unsafe operation and presents choices to keep it queued, discard that item and diff --git a/health.go b/health.go index ab211be..ec7119c 100644 --- a/health.go +++ b/health.go @@ -36,6 +36,17 @@ func (r *requestCoordinator) current(id uint64) bool { return r.id == id } +func (r *requestCoordinator) supersede() uint64 { + r.mu.Lock() + defer r.mu.Unlock() + if r.cancel != nil { + r.cancel() + r.cancel = nil + } + r.id++ + return r.id +} + type HealthLevel string const ( diff --git a/health_test.go b/health_test.go index a881d24..f6897b2 100644 --- a/health_test.go +++ b/health_test.go @@ -145,7 +145,7 @@ func TestRequestCoordinatorCancelsSupersededRequest(t *testing.T) { var coordinator requestCoordinator first, cancelFirst, firstID := coordinator.start(time.Minute) defer cancelFirst() - _, cancelSecond, secondID := coordinator.start(time.Minute) + second, cancelSecond, secondID := coordinator.start(time.Minute) defer cancelSecond() select { case <-first.Done(): @@ -159,6 +159,16 @@ func TestRequestCoordinatorCancelsSupersededRequest(t *testing.T) { if !errors.Is(first.Err(), context.Canceled) { t.Fatalf("first context error = %v", first.Err()) } + claimedID := coordinator.supersede() + select { + case <-second.Done(): + default: + t.Fatal("claimed snapshot did not cancel the active request") + } + if coordinator.current(secondID) || !coordinator.current(claimedID) { + t.Fatalf("claimed request ids: second=%t claimed=%t", + coordinator.current(secondID), coordinator.current(claimedID)) + } } func TestPartialRefreshPreservesLastCompleteSubsections(t *testing.T) { diff --git a/mutation_queue.go b/mutation_queue.go index e38d9f5..f32e162 100644 --- a/mutation_queue.go +++ b/mutation_queue.go @@ -27,28 +27,30 @@ const ( ) type mutationOperation struct { - ID string `json:"id"` - Kind mutationKind `json:"kind"` - Owner string `json:"owner"` - Repository string `json:"repository"` - Number int `json:"number"` - PRID string `json:"pull_request_id"` - ThreadID string `json:"thread_id,omitempty"` - Body string `json:"body,omitempty"` - Resolved bool `json:"resolved,omitempty"` - Viewer string `json:"viewer,omitempty"` - Original PullRequestMetadata `json:"original,omitempty"` - Update PullRequestMetadata `json:"update,omitempty"` - Permissions ViewerPermissions `json:"permissions"` - ThreadCanReply bool `json:"thread_can_reply,omitempty"` - ThreadCanResolve bool `json:"thread_can_resolve,omitempty"` - ThreadCanUnresolve bool `json:"thread_can_unresolve,omitempty"` - PeopleDone bool `json:"people_done,omitempty"` - Attempted bool `json:"attempted,omitempty"` - Blocked bool `json:"blocked,omitempty"` - Ambiguous bool `json:"ambiguous,omitempty"` - LastError string `json:"last_error,omitempty"` - EnqueuedAt time.Time `json:"enqueued_at"` + ID string `json:"id"` + Kind mutationKind `json:"kind"` + Owner string `json:"owner"` + Repository string `json:"repository"` + Number int `json:"number"` + PRID string `json:"pull_request_id"` + ThreadID string `json:"thread_id,omitempty"` + Body string `json:"body,omitempty"` + Resolved bool `json:"resolved,omitempty"` + Viewer string `json:"viewer,omitempty"` + Original PullRequestMetadata `json:"original,omitempty"` + Update PullRequestMetadata `json:"update,omitempty"` + Permissions ViewerPermissions `json:"permissions"` + ThreadCanReply bool `json:"thread_can_reply,omitempty"` + ThreadCanResolve bool `json:"thread_can_resolve,omitempty"` + ThreadCanUnresolve bool `json:"thread_can_unresolve,omitempty"` + PeopleDone bool `json:"people_done,omitempty"` + Attempted bool `json:"attempted,omitempty"` + AwaitingVerification bool `json:"awaiting_verification,omitempty"` + Unverified bool `json:"unverified,omitempty"` + Blocked bool `json:"blocked,omitempty"` + Ambiguous bool `json:"ambiguous,omitempty"` + LastError string `json:"last_error,omitempty"` + EnqueuedAt time.Time `json:"enqueued_at"` } type mutationQueueEnvelope struct { @@ -235,6 +237,7 @@ type mutationReplayState int const ( mutationReplayApplied mutationReplayState = iota + mutationReplayVerifying mutationReplayWaiting mutationReplayBlocked ) @@ -303,18 +306,51 @@ func executeQueuedMutation( operation mutationOperation, details PRDetails, ) mutationReplayMsg { blocked := func(reason string, ambiguous bool) mutationReplayMsg { + operation.AwaitingVerification = false + operation.Unverified = true + if operation.Kind == mutationPREdit { + operation.PeopleDone = false + } operation.Blocked, operation.Ambiguous, operation.LastError = true, ambiguous, reason if err := store.update(operation); err != nil { return mutationReplayMsg{operation: operation, state: mutationReplayBlocked, details: details, reason: reason, err: err} } return mutationReplayMsg{operation: operation, state: mutationReplayBlocked, details: details, reason: reason} } + verifying := func() mutationReplayMsg { + operation.AwaitingVerification = true + operation.Blocked, operation.Ambiguous, operation.LastError = false, false, "" + if err := store.update(operation); err != nil { + return blocked("could not checkpoint successful mutation for verification", true) + } + return mutationReplayMsg{ + operation: operation, state: mutationReplayVerifying, details: details, + } + } + markUnverified := func() *mutationReplayMsg { + if !operation.AwaitingVerification { + return nil + } + operation.AwaitingVerification = false + operation.Unverified = true + if operation.Kind == mutationPREdit { + operation.PeopleDone = false + } + if err := store.update(operation); err != nil { + result := blocked("could not record failed mutation verification", true) + return &result + } + return nil + } thread := findReviewThread(details.Threads, operation.ThreadID) switch operation.Kind { case mutationReply: if queuedReplyPresent(details, operation) { return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} } + if result := markUnverified(); result != nil { + return *result + } if thread == nil { return blocked("the review thread no longer exists", false) } @@ -339,7 +375,7 @@ func executeQueuedMutation( if _, err := writer.ReplyToThread(ctx, operation.ThreadID, operation.Body); err != nil { return mutationReplayMsg{operation: operation, state: mutationReplayWaiting, details: details, err: err} } - return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} + return verifying() case mutationResolution: if thread == nil { return blocked("the review thread no longer exists", false) @@ -347,6 +383,9 @@ func executeQueuedMutation( if thread.IsResolved == operation.Resolved { return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} } + if result := markUnverified(); result != nil { + return *result + } if operation.Resolved && !thread.ViewerCanResolve { return blocked("GitHub no longer grants resolve permission for this thread", false) } @@ -368,8 +407,14 @@ func executeQueuedMutation( if _, err := writer.SetThreadResolved(ctx, operation.ThreadID, operation.Resolved); err != nil { return mutationReplayMsg{operation: operation, state: mutationReplayWaiting, details: details, err: err} } - return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} + return verifying() case mutationPREdit: + if queuedPREditPresent(details, operation.Update) { + return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} + } + if result := markUnverified(); result != nil { + return *result + } if !details.Permissions.CanUpdatePR { return blocked("GitHub no longer grants permission to update this pull request", false) } @@ -404,12 +449,18 @@ func executeQueuedMutation( } details.Title, details.Body, details.BaseRef = result.Title, result.Body, result.BaseRef } - return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} + return verifying() default: return blocked("queued mutation has an unsupported kind", false) } } +func queuedPREditPresent(details PRDetails, update PullRequestMetadata) bool { + return samePRMetadataCore(update, currentMetadata(details)) && + slices.Equal(normalizedLogins(update.Reviewers), normalizedLogins(details.RequestedReviewers)) && + slices.Equal(normalizedLogins(update.Assignees), normalizedLogins(details.Assignees)) +} + func findReviewThread(threads []ReviewThread, id string) *ReviewThread { for index := range threads { if threads[index].ID == id { @@ -494,30 +545,38 @@ func (m App) projectQueuedMutations(details PRDetails) PRDetails { } pendingID := "pending:" + operation.ID found := false - for _, comment := range thread.Comments { - found = found || comment.ID == pendingID + for index := range thread.Comments { + if thread.Comments[index].ID == pendingID { + thread.Comments[index].Pending = mutationNeedsAttention(operation) + found = true + } } if !found { thread.Comments = append(thread.Comments, ReviewComment{ ID: pendingID, Author: operation.Viewer, Body: operation.Body, - CreatedAt: operation.EnqueuedAt, Pending: true, + CreatedAt: operation.EnqueuedAt, Pending: mutationNeedsAttention(operation), }) } case mutationResolution: if thread := findReviewThread(details.Threads, operation.ThreadID); thread != nil { - thread.IsResolved, thread.Pending = operation.Resolved, true + thread.IsResolved = operation.Resolved + thread.Pending = mutationNeedsAttention(operation) } case mutationPREdit: details.Title, details.Body, details.BaseRef = operation.Update.Title, operation.Update.Body, operation.Update.BaseRef details.RequestedReviewers = slices.Clone(operation.Update.Reviewers) details.Assignees = slices.Clone(operation.Update.Assignees) details.Reviewers = projectRequestedReviewers(details.Reviewers, operation.Update.Reviewers) - details.Pending = true + details.Pending = mutationNeedsAttention(operation) } } return details } +func mutationNeedsAttention(operation mutationOperation) bool { + return operation.Blocked || operation.Unverified +} + func (m App) projectQueuedPullRequests(prs []PullRequest) []PullRequest { result := slices.Clone(prs) for _, operation := range m.mutations.list() { @@ -527,7 +586,8 @@ func (m App) projectQueuedPullRequests(prs []PullRequest) []PullRequest { for index := range result { if result[index].Owner == operation.Owner && result[index].Repository == operation.Repository && result[index].Number == operation.Number { - result[index].Title, result[index].Pending = operation.Update.Title, true + result[index].Title = operation.Update.Title + result[index].Pending = mutationNeedsAttention(operation) } } } @@ -592,7 +652,8 @@ func (m *App) resolveBlockedMutation(choice int) tea.Cmd { } return command case "Retry this mutation now": - operation.Attempted, operation.Blocked = false, false + operation.Attempted, operation.AwaitingVerification = false, false + operation.Unverified, operation.Blocked = false, false operation.Ambiguous, operation.LastError = false, "" if err := m.mutations.update(operation); err != nil { m.err = err diff --git a/mutation_queue_test.go b/mutation_queue_test.go index 662a328..efd9ea8 100644 --- a/mutation_queue_test.go +++ b/mutation_queue_test.go @@ -150,9 +150,9 @@ func TestQueuedMutationsProjectWithoutChangingSnapshot(t *testing.T) { if snapshot.Title != "remote" || snapshot.Threads[0].IsResolved || len(snapshot.Threads[0].Comments) != 0 { t.Fatalf("source snapshot was mutated: %#v", snapshot) } - if projected.Title != "queued" || !projected.Pending || !projected.Threads[0].IsResolved || - !projected.Threads[0].Pending || len(projected.Threads[0].Comments) != 1 || - !projected.Threads[0].Comments[0].Pending { + if projected.Title != "queued" || projected.Pending || !projected.Threads[0].IsResolved || + projected.Threads[0].Pending || len(projected.Threads[0].Comments) != 1 || + projected.Threads[0].Comments[0].Pending { t.Fatalf("projection = %#v", projected) } } @@ -173,7 +173,7 @@ func TestCachedGrantedPermissionQueuesReply(t *testing.T) { updated, command := m.Update(message) m = updated.(App) if store.count() != 1 || m.writeMode != writeNone || - len(m.details.Threads[0].Comments) != 1 || !m.details.Threads[0].Comments[0].Pending { + len(m.details.Threads[0].Comments) != 1 || m.details.Threads[0].Comments[0].Pending { t.Fatalf("queued cached reply: count=%d mode=%d details=%#v command=%v", store.count(), m.writeMode, m.details, command) } @@ -303,6 +303,87 @@ func TestReplayReconcilesReplyBeforeAskingAboutAmbiguousDelivery(t *testing.T) { } } +func TestSuccessfulResolutionWaitsForLiveVerification(t *testing.T) { + store := loadMutationQueue("") + operation := mutationOperation{ + ID: "resolution", Kind: mutationResolution, Owner: "o", Repository: "r", Number: 1, + ThreadID: "thread", Resolved: true, EnqueuedAt: time.Now(), + } + if err := store.add(operation); err != nil { + t.Fatal(err) + } + service := &recordingService{} + details := PRDetails{Threads: []ReviewThread{{ID: "thread", ViewerCanResolve: true}}} + + result := executeQueuedMutation(context.Background(), service, store, operation, details) + if result.state != mutationReplayVerifying || !service.writeResolved { + t.Fatalf("successful resolution = %#v service=%#v", result, service) + } + stored, ok := store.front() + if !ok || !stored.AwaitingVerification || stored.Unverified || stored.Blocked { + t.Fatalf("resolution awaiting verification = %#v", stored) + } + + details.Threads[0].IsResolved = true + result = executeQueuedMutation(context.Background(), service, store, stored, details) + if result.state != mutationReplayApplied { + t.Fatalf("verified resolution = %#v", result) + } +} + +func TestRetryableReplyFailureRemainsOptimisticallyApplied(t *testing.T) { + store := loadMutationQueue("") + operation := mutationOperation{ + ID: "reply", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1, + ThreadID: "thread", Body: "body", Viewer: "me", EnqueuedAt: time.Now(), + } + if err := store.add(operation); err != nil { + t.Fatal(err) + } + details := PRDetails{ + PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1}, + Threads: []ReviewThread{{ID: "thread", ViewerCanReply: true}}, + } + result := executeQueuedMutation( + context.Background(), &failingReplyService{}, store, operation, details, + ) + if result.state != mutationReplayWaiting || result.err == nil { + t.Fatalf("retryable reply = %#v", result) + } + m := NewApp(nil, "o", "r", false, 50, time.Minute) + m.mutations = store + projected := m.projectQueuedMutations(details) + if len(projected.Threads[0].Comments) != 1 || projected.Threads[0].Comments[0].Pending { + t.Fatalf("retryable reply was not optimistic: %#v", projected.Threads[0].Comments) + } +} + +func TestFailedReplyVerificationMarksOptimisticCommentForAttention(t *testing.T) { + store := loadMutationQueue("") + operation := mutationOperation{ + ID: "reply", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1, + ThreadID: "thread", Body: "body", Viewer: "me", Attempted: true, + AwaitingVerification: true, EnqueuedAt: time.Now(), + } + if err := store.add(operation); err != nil { + t.Fatal(err) + } + details := PRDetails{ + PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1}, + Threads: []ReviewThread{{ID: "thread", ViewerCanReply: true}}, + } + result := executeQueuedMutation(context.Background(), &recordingService{}, store, operation, details) + if result.state != mutationReplayBlocked || !result.operation.Unverified { + t.Fatalf("failed verification = %#v", result) + } + m := NewApp(nil, "o", "r", false, 50, time.Minute) + m.mutations = store + projected := m.projectQueuedMutations(details) + if len(projected.Threads[0].Comments) != 1 || !projected.Threads[0].Comments[0].Pending { + t.Fatalf("unverified reply was not marked for attention: %#v", projected.Threads[0].Comments) + } +} + func TestQueuedPREditThreeWayMergeOnlyBlocksConflictingFields(t *testing.T) { base := PullRequestMetadata{Title: "old", Body: "old body", BaseRef: "main"} desired := base diff --git a/pr_editor.go b/pr_editor.go index bc6cf3d..b29b43d 100644 --- a/pr_editor.go +++ b/pr_editor.go @@ -102,7 +102,7 @@ func (m *App) loadPREditUsers() tea.Cmd { } func (m App) pullRequestUpdateUnavailable() string { - if m.loading { + if m.loading && m.mutations == nil { return "pull request update unavailable while PR data is refreshing" } if m.details.FromCache && m.mutations == nil { diff --git a/tui.go b/tui.go index a3c3543..d95d8b6 100644 --- a/tui.go +++ b/tui.go @@ -577,7 +577,7 @@ func (m App) writeActionUnavailable(action string, thread *ReviewThread) string } return "" } - if m.loading { + if m.loading && m.mutations == nil { return "write action unavailable while PR data is refreshing" } if m.details.FromCache && m.mutations == nil { @@ -999,7 +999,23 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.difflet.setState(diffletRecoverableError) } + selected := "" + if msg.operation.Kind == mutationResolution { + selected = m.selectionAfterResolution( + msg.operation.ThreadID, msg.operation.Resolved, + ) + } m.details = m.projectQueuedMutations(m.details) + if msg.operation.Kind == mutationResolution { + if thread := m.threadByID(msg.operation.ThreadID); thread != nil { + m.folded[thread.ID] = thread.IsResolved && m.foldResolved + } + if msg.operation.Resolved { + m.markThreadRead(msg.operation.ThreadID) + } + sortReviewThreads(m.details.Threads, m.threadStatusOrder, m.threadWithinStatus) + m.threadIndex = indexThread(m.details.Threads, selected) + } m.err = nil switch msg.operation.Kind { case mutationReply: @@ -1017,18 +1033,35 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.writeMode = writeNone if m.details.FromCache { - return m, m.difflet.setState(diffletIdle) + return m, m.difflet.setState(diffletSuccess) } - return m, tea.Batch(m.startMutationReplay(), m.difflet.setState(diffletLoading)) + return m, tea.Batch(m.startMutationReplay(), m.difflet.setState(diffletSuccess)) case mutationReplayMsg: m.mutationReplayBusy = false if msg.state == mutationReplayWaiting { if msg.err != nil { m.recordHealth("mutation replay", healthWarning, msg.err.Error()) } - return m, m.difflet.setState(diffletRecoverableError) + m.details = m.projectQueuedMutations(m.details) + m.err = nil + return m, m.difflet.setState(diffletSuccess) + } + if msg.state == mutationReplayVerifying { + m.details = m.projectQueuedMutations(m.details) + m.err = nil + if m.details.Owner == msg.operation.Owner && + m.details.Repository == msg.operation.Repository && + m.details.Number == msg.operation.Number { + m.loading = true + return m, tea.Batch( + m.loadDetails(m.details.PullRequest, false), + m.difflet.setState(diffletSuccess), + ) + } + return m, m.difflet.setState(diffletSuccess) } if msg.state == mutationReplayBlocked { + m.details = m.projectQueuedMutations(m.details) m.blockedMutation = &msg.operation m.blockedMutationDetails = msg.details m.blockedMutationChoice = 0 @@ -1046,8 +1079,18 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.details.Owner == msg.operation.Owner && m.details.Repository == msg.operation.Repository && m.details.Number == msg.operation.Number { m.loading = true + details := msg.details + requestID := uint64(0) + if m.requests != nil { + requestID = m.requests.supersede() + } return m, tea.Batch( - m.loadDetails(m.details.PullRequest, false), + func() tea.Msg { + return detailsLoadedMsg{ + owner: details.Owner, repo: details.Repository, number: details.Number, + details: details, requestID: requestID, + } + }, m.difflet.setState(diffletSuccess), ) } @@ -1091,17 +1134,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.recordHealth("thread resolution", healthError, msg.err.Error()) return m, m.difflet.setState(diffletRecoverableError) } - selected := "" - if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) { - selected = m.details.Threads[m.threadIndex].ID - } - if msg.thread.IsResolved && selected == msg.threadID { - if m.threadIndex+1 < len(m.details.Threads) { - selected = m.details.Threads[m.threadIndex+1].ID - } else if m.threadIndex > 0 { - selected = m.details.Threads[m.threadIndex-1].ID - } - } + selected := m.selectionAfterResolution(msg.threadID, msg.thread.IsResolved) for index := range m.details.Threads { if m.details.Threads[index].ID != msg.threadID { continue @@ -2460,6 +2493,23 @@ func (m App) threadByID(id string) *ReviewThread { return nil } +func (m App) selectionAfterResolution(threadID string, resolved bool) string { + if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) { + return "" + } + selected := m.details.Threads[m.threadIndex].ID + if !resolved || selected != threadID { + return selected + } + if m.threadIndex+1 < len(m.details.Threads) { + return m.details.Threads[m.threadIndex+1].ID + } + if m.threadIndex > 0 { + return m.details.Threads[m.threadIndex-1].ID + } + return selected +} + type helpBinding struct { key string action string diff --git a/tui_test.go b/tui_test.go index 7e25d0e..70da7a7 100644 --- a/tui_test.go +++ b/tui_test.go @@ -1629,6 +1629,60 @@ func TestResolvingSelectedThreadKeepsSelectionNearItsPreviousPosition(t *testing } } +func TestQueuedResolutionOptimisticallyMovesToNeighborWithoutPendingLabel(t *testing.T) { + store := loadMutationQueue("") + operation := mutationOperation{ + ID: "resolve", Kind: mutationResolution, Owner: "o", Repository: "r", Number: 1, + ThreadID: "b", Resolved: true, EnqueuedAt: time.Now(), + } + if err := store.add(operation); err != nil { + t.Fatal(err) + } + settings := defaultAppSettings() + settings.Mutations = store + m := NewAppWithSettings(nil, "o", "r", false, 50, time.Minute, settings) + m.screen, m.loading, m.focus = threadScreen, false, threadDetailPane + m.details = PRDetails{ + PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1}, + Threads: []ReviewThread{ + {ID: "a", Path: "a.go"}, + {ID: "b", Path: "b.go"}, + {ID: "c", Path: "c.go"}, + }, + } + m.threadIndex = 1 + + updated, _ := m.Update(mutationQueuedMsg{operation: operation}) + m = updated.(App) + if got := m.details.Threads[m.threadIndex].ID; got != "c" { + t.Fatalf("selected thread = %q, want c", got) + } + thread := m.threadByID("b") + if thread == nil || !thread.IsResolved || thread.Pending { + t.Fatalf("optimistic resolved thread = %#v", thread) + } +} + +func TestMutationQueueAllowsResolvingNextThreadDuringVerificationRefresh(t *testing.T) { + store := loadMutationQueue("") + settings := defaultAppSettings() + settings.Mutations = store + m := NewAppWithSettings(&recordingService{}, "o", "r", false, 50, time.Minute, settings) + m.screen, m.loading = threadScreen, true + m.details = PRDetails{ + PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1}, + Threads: []ReviewThread{{ + ID: "next", ViewerCanResolve: true, + }}, + } + + m.startResolveToggle() + if m.err != nil || m.writeMode != writeResolveConfirm || !m.resolveTarget { + t.Fatalf("resolve during verification mode=%d target=%t err=%v", + m.writeMode, m.resolveTarget, m.err) + } +} + func TestPollingMarksNewThreadCommentsUnread(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.width, m.height = threadScreen, 80, 8 diff --git a/version.go b/version.go index 20e5166..c4050ca 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const dipleVersion = "0.5.0" +const dipleVersion = "0.5.1"