From cc5b4eba1e9bc0f57d05e9b182be49ecd2ed3b3f Mon Sep 17 00:00:00 2001 From: pablu Date: Mon, 3 Aug 2026 12:14:31 +0200 Subject: [PATCH] feat: queued mutations while reloading or offline --- README.md | 25 +- TODO.md | 6 +- ai_tui.go | 3 + main.go | 2 + mutation_queue.go | 595 +++++++++++++++++++++++++++++++++++++++++ mutation_queue_test.go | 214 +++++++++++++++ pr_editor.go | 23 +- tui.go | 213 +++++++++++++-- tui_test.go | 10 +- types.go | 3 + version.go | 2 +- 11 files changed, 1068 insertions(+), 28 deletions(-) create mode 100644 mutation_queue.go create mode 100644 mutation_queue_test.go diff --git a/README.md b/README.md index 23f84fe..c8b02d8 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,11 @@ When GitHub reports that the authenticated user has permission, diple can: The UI explains unavailable actions through its write-capability gate. Metadata and reply drafts are persisted locally so cancellation or a restart -does not silently discard work. +does not silently discard work. Replies, thread resolution changes, and PR +metadata or people edits are also placed in a durable FIFO queue before they +are sent. If the most recent cached snapshot granted the action, it may be +queued while offline and is replayed automatically after connectivity returns. +Merge and auto-merge actions remain online-only. Reactions are currently read-only. Assigning labels or milestones is not implemented yet. @@ -521,9 +525,22 @@ 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`. 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. +`state.json` and `drafts.json`. Confirmed reversible GitHub writes are kept in +`mutation-queue.json` until GitHub confirms 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. + +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 +continue, or discard the remaining queued changes for that PR. A lost reply +response is checked against fresh GitHub thread data first; only when delivery +cannot be determined does diple ask whether to retry or treat it as applied. ## Experimental local AI review diff --git a/TODO.md b/TODO.md index ace1b5b..905b54c 100644 --- a/TODO.md +++ b/TODO.md @@ -2,9 +2,9 @@ This list reflects the current implementation: paginated review threads, thread comments, conversation comments, reviews, timeline events, checks, and -annotations; cached read-only snapshots; persistent unread state; contextual -keybindings; thread replies and resolution changes; and pull-request metadata -editing are already implemented. +annotations; cached snapshots with durable ordered offline writes; persistent +unread state; contextual keybindings; thread replies and resolution changes; +and pull-request metadata editing are already implemented. ## Experimental AI follow-up diff --git a/ai_tui.go b/ai_tui.go index acc504d..2af75fb 100644 --- a/ai_tui.go +++ b/ai_tui.go @@ -658,6 +658,9 @@ func renderAIProgressBar(width int, progress AIRunProgress, spinner int) string } func localAICommentBadge(comment ReviewComment) string { + if comment.Pending { + return " " + warnStyle.Render("[PENDING]") + } switch comment.Origin { case reviewOriginLocalAI: return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]") diff --git a/main.go b/main.go index c491c3e..10e8984 100644 --- a/main.go +++ b/main.go @@ -155,6 +155,7 @@ func main() { } statePath := filepath.Join(filepath.Dir(*configFile), "state.json") draftPath := filepath.Join(filepath.Dir(*configFile), "drafts.json") + mutationQueuePath := filepath.Join(filepath.Dir(*configFile), "mutation-queue.json") var aiController *AIController var aiStore *AIStore if config.AI.Enabled { @@ -189,6 +190,7 @@ func main() { ViewerLabel: config.Display.ViewerLabel, ReadState: loadReadState(statePath), Drafts: loadDraftStore(draftPath), + Mutations: loadMutationQueue(mutationQueuePath), PathScroll: config.Paths.Scroll, PathScrollInterval: config.Paths.ScrollInterval.Duration, ThreadStatusOrder: config.Threads.StatusOrder, diff --git a/mutation_queue.go b/mutation_queue.go new file mode 100644 index 0000000..a83d11b --- /dev/null +++ b/mutation_queue.go @@ -0,0 +1,595 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +const mutationQueueSchemaVersion = 1 + +type mutationKind string + +const ( + mutationReply mutationKind = "reply" + mutationResolution mutationKind = "resolution" + mutationPREdit mutationKind = "pr-edit" +) + +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"` +} + +type mutationQueueEnvelope struct { + Version int `json:"version"` + Operations []mutationOperation `json:"operations"` +} + +type mutationQueueStore struct { + mu sync.Mutex + path string + operations []mutationOperation + loadErr error +} + +func loadMutationQueue(path string) *mutationQueueStore { + store := &mutationQueueStore{path: path} + if path == "" { + return store + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return store + } + if err != nil { + store.loadErr = err + return store + } + var envelope mutationQueueEnvelope + if err := json.Unmarshal(data, &envelope); err != nil || + envelope.Version != mutationQueueSchemaVersion { + store.loadErr = errors.New("mutation queue is corrupt or has an unsupported schema version") + return store + } + store.operations = envelope.Operations + return store +} + +var mutationSequence atomic.Uint64 + +func newMutationID() string { + return fmt.Sprintf("%d-%d", time.Now().UnixNano(), mutationSequence.Add(1)) +} + +func (s *mutationQueueStore) add(operation mutationOperation) error { + if s == nil { + return errors.New("mutation queue is unavailable") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.loadErr != nil { + return fmt.Errorf("mutation queue unavailable: %w", s.loadErr) + } + if operation.ID == "" { + operation.ID = newMutationID() + } + if operation.EnqueuedAt.IsZero() { + operation.EnqueuedAt = time.Now() + } + s.operations = append(s.operations, operation) + if err := s.flushLocked(); err != nil { + s.operations = s.operations[:len(s.operations)-1] + return err + } + return nil +} + +func (s *mutationQueueStore) front() (mutationOperation, bool) { + if s == nil { + return mutationOperation{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.operations) == 0 { + return mutationOperation{}, false + } + return s.operations[0], true +} + +func (s *mutationQueueStore) get(id string) (mutationOperation, bool) { + for _, operation := range s.list() { + if operation.ID == id { + return operation, true + } + } + return mutationOperation{}, false +} + +func (s *mutationQueueStore) list() []mutationOperation { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return slices.Clone(s.operations) +} + +func (s *mutationQueueStore) count() int { return len(s.list()) } + +func (s *mutationQueueStore) update(operation mutationOperation) error { + if s == nil { + return errors.New("mutation queue is unavailable") + } + s.mu.Lock() + defer s.mu.Unlock() + for index := range s.operations { + if s.operations[index].ID == operation.ID { + s.operations[index] = operation + return s.flushLocked() + } + } + return errors.New("queued mutation no longer exists") +} + +func (s *mutationQueueStore) remove(id string) error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + for index := range s.operations { + if s.operations[index].ID == id { + s.operations = append(s.operations[:index], s.operations[index+1:]...) + return s.flushLocked() + } + } + return nil +} + +func (s *mutationQueueStore) removePR(owner, repo string, number int) error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + filtered := s.operations[:0] + for _, operation := range s.operations { + if operation.Owner != owner || operation.Repository != repo || operation.Number != number { + filtered = append(filtered, operation) + } + } + s.operations = filtered + return s.flushLocked() +} + +func (s *mutationQueueStore) flushLocked() error { + if s.path == "" { + return nil + } + return atomicWriteJSON(s.path, mutationQueueEnvelope{ + Version: mutationQueueSchemaVersion, Operations: s.operations, + }, 0o600) +} + +type mutationQueuedMsg struct { + operation mutationOperation + err error +} + +type mutationReplayState int + +const ( + mutationReplayApplied mutationReplayState = iota + mutationReplayWaiting + mutationReplayBlocked +) + +type mutationReplayMsg struct { + operation mutationOperation + state mutationReplayState + details PRDetails + reason string + err error +} + +func (m App) enqueueMutation(operation mutationOperation) tea.Cmd { + return func() tea.Msg { + operation.ID = newMutationID() + operation.EnqueuedAt = time.Now() + err := m.mutations.add(operation) + return mutationQueuedMsg{operation: operation, err: err} + } +} + +func (m App) replaceMutation(operation mutationOperation) tea.Cmd { + return func() tea.Msg { + err := m.mutations.update(operation) + return mutationQueuedMsg{operation: operation, err: err} + } +} + +func (m App) replayNextMutation() tea.Cmd { + if m.mutations == nil { + return nil + } + operation, ok := m.mutations.front() + if !ok { + return nil + } + service := m.service + store := m.mutations + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + var details PRDetails + var err error + if live, ok := service.(liveGitHubService); ok { + details, err = live.LivePullRequest(ctx, operation.Owner, operation.Repository, operation.Number) + } else { + details, err = service.GetPullRequest(ctx, operation.Owner, operation.Repository, operation.Number) + } + if err != nil || details.FromCache { + return mutationReplayMsg{operation: operation, state: mutationReplayWaiting, err: err} + } + return executeQueuedMutation(ctx, service, store, operation, details) + } +} + +func (m *App) startMutationReplay() tea.Cmd { + if m.mutations == nil || m.mutationReplayBusy || m.mutations.count() == 0 { + return nil + } + m.mutationReplayBusy = true + return m.replayNextMutation() +} + +func executeQueuedMutation( + ctx context.Context, service GitHubService, store *mutationQueueStore, + operation mutationOperation, details PRDetails, +) mutationReplayMsg { + blocked := func(reason string, ambiguous bool) mutationReplayMsg { + 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} + } + thread := findReviewThread(details.Threads, operation.ThreadID) + switch operation.Kind { + case mutationReply: + if queuedReplyPresent(details, operation) { + return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} + } + if thread == nil { + return blocked("the review thread no longer exists", false) + } + if !thread.ViewerCanReply { + return blocked("GitHub no longer grants reply permission for this thread", false) + } + if operation.Attempted { + return blocked("GitHub data cannot prove whether the previous reply attempt was applied", true) + } + writer, ok := service.(GitHubWriteService) + if !ok { + return blocked("configured GitHub service no longer supports replies", false) + } + operation.Attempted = true + _ = store.update(operation) + if _, err := writer.ReplyToThread(ctx, operation.ThreadID, operation.Body); err != nil { + operation.LastError = err.Error() + _ = store.update(operation) + return mutationReplayMsg{operation: operation, state: mutationReplayWaiting, details: details, err: err} + } + return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} + case mutationResolution: + if thread == nil { + return blocked("the review thread no longer exists", false) + } + if thread.IsResolved == operation.Resolved { + return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} + } + if operation.Resolved && !thread.ViewerCanResolve { + return blocked("GitHub no longer grants resolve permission for this thread", false) + } + if !operation.Resolved && !thread.ViewerCanUnresolve { + return blocked("GitHub no longer grants unresolve permission for this thread", false) + } + writer, ok := service.(GitHubWriteService) + if !ok { + return blocked("configured GitHub service no longer supports thread updates", false) + } + operation.Attempted = true + _ = store.update(operation) + if _, err := writer.SetThreadResolved(ctx, operation.ThreadID, operation.Resolved); err != nil { + operation.LastError = err.Error() + _ = store.update(operation) + return mutationReplayMsg{operation: operation, state: mutationReplayWaiting, details: details, err: err} + } + return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} + case mutationPREdit: + if !details.Permissions.CanUpdatePR { + return blocked("GitHub no longer grants permission to update this pull request", false) + } + update, conflict := rebaseQueuedPREdit(operation.Original, operation.Update, currentMetadata(details)) + if conflict != "" { + return blocked("pull request fields changed on GitHub: "+conflict, false) + } + peopleWriter, peopleOK := service.(GitHubPullRequestPeopleWriteService) + writer, writeOK := service.(GitHubPullRequestWriteService) + if !peopleOK || !writeOK { + return blocked("configured GitHub service no longer supports pull request updates", false) + } + if !operation.PeopleDone && (!slices.Equal(update.Reviewers, details.RequestedReviewers) || + !slices.Equal(update.Assignees, details.Assignees)) { + people, err := peopleWriter.UpdatePullRequestPeople(ctx, operation.Owner, operation.Repository, operation.Number, PullRequestPeopleUpdate{ + CurrentReviewers: slices.Clone(details.RequestedReviewers), CurrentAssignees: slices.Clone(details.Assignees), + Reviewers: slices.Clone(update.Reviewers), Assignees: slices.Clone(update.Assignees), + }) + if err != nil { + operation.Attempted, operation.LastError = true, err.Error() + _ = store.update(operation) + return mutationReplayMsg{operation: operation, state: mutationReplayWaiting, details: details, err: err} + } + details.RequestedReviewers, details.Assignees = people.Reviewers, people.Assignees + operation.PeopleDone = true + if err := store.update(operation); err != nil { + return blocked("could not checkpoint the applied reviewer and assignee update", true) + } + } + if !samePRMetadataCore(update, currentMetadata(details)) { + result, err := writer.UpdatePullRequest(ctx, operation.PRID, update) + if err != nil { + operation.Attempted, operation.LastError = true, err.Error() + _ = store.update(operation) + return mutationReplayMsg{operation: operation, state: mutationReplayWaiting, details: details, err: err} + } + details.Title, details.Body, details.BaseRef = result.Title, result.Body, result.BaseRef + } + return mutationReplayMsg{operation: operation, state: mutationReplayApplied, details: details} + default: + return blocked("queued mutation has an unsupported kind", false) + } +} + +func findReviewThread(threads []ReviewThread, id string) *ReviewThread { + for index := range threads { + if threads[index].ID == id { + return &threads[index] + } + } + return nil +} + +func queuedReplyPresent(details PRDetails, operation mutationOperation) bool { + thread := findReviewThread(details.Threads, operation.ThreadID) + if thread == nil { + return false + } + matches := 0 + for _, comment := range thread.Comments { + if comment.Body == operation.Body && strings.EqualFold(comment.Author, operation.Viewer) && + !comment.CreatedAt.Before(operation.EnqueuedAt.Add(-time.Minute)) { + matches++ + } + } + return matches == 1 +} + +func currentMetadata(details PRDetails) PullRequestMetadata { + return PullRequestMetadata{ + Title: details.Title, Body: details.Body, BaseRef: details.BaseRef, + Reviewers: normalizedLogins(details.RequestedReviewers), Assignees: normalizedLogins(details.Assignees), + Mergeable: details.Mergeable, MergeState: details.MergeState, UpdatedAt: details.UpdatedAt, + } +} + +func rebaseQueuedPREdit(base, desired, remote PullRequestMetadata) (PullRequestMetadata, string) { + result := remote + conflicts := []string{} + rebaseString := func(name, before, want, current string) string { + switch { + case want == before: + return current + case current == before || current == want: + return want + default: + conflicts = append(conflicts, name) + return current + } + } + result.Title = rebaseString("title", base.Title, desired.Title, remote.Title) + result.Body = rebaseString("description", base.Body, desired.Body, remote.Body) + result.BaseRef = rebaseString("target branch", base.BaseRef, desired.BaseRef, remote.BaseRef) + rebaseLogins := func(name string, before, want, current []string) []string { + switch { + case slices.Equal(want, before): + return current + case slices.Equal(current, before) || slices.Equal(current, want): + return want + default: + conflicts = append(conflicts, name) + return current + } + } + result.Reviewers = rebaseLogins("reviewers", base.Reviewers, desired.Reviewers, remote.Reviewers) + result.Assignees = rebaseLogins("assignees", base.Assignees, desired.Assignees, remote.Assignees) + return result, strings.Join(conflicts, ", ") +} + +func (m App) projectQueuedMutations(details PRDetails) PRDetails { + details.Threads = slices.Clone(details.Threads) + for index := range details.Threads { + details.Threads[index].Comments = slices.Clone(details.Threads[index].Comments) + } + details.RequestedReviewers = slices.Clone(details.RequestedReviewers) + details.Assignees = slices.Clone(details.Assignees) + for _, operation := range m.mutations.list() { + if operation.Owner != details.Owner || operation.Repository != details.Repository || operation.Number != details.Number { + continue + } + switch operation.Kind { + case mutationReply: + thread := findReviewThread(details.Threads, operation.ThreadID) + if thread == nil { + continue + } + pendingID := "pending:" + operation.ID + found := false + for _, comment := range thread.Comments { + found = found || comment.ID == pendingID + } + if !found { + thread.Comments = append(thread.Comments, ReviewComment{ + ID: pendingID, Author: operation.Viewer, Body: operation.Body, + CreatedAt: operation.EnqueuedAt, Pending: true, + }) + } + case mutationResolution: + if thread := findReviewThread(details.Threads, operation.ThreadID); thread != nil { + thread.IsResolved, thread.Pending = operation.Resolved, true + } + 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 + } + } + return details +} + +func (m App) projectQueuedPullRequests(prs []PullRequest) []PullRequest { + result := slices.Clone(prs) + for _, operation := range m.mutations.list() { + if operation.Kind != mutationPREdit { + continue + } + 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 + } + } + } + return result +} + +func projectRequestedReviewers(current []Reviewer, requested []string) []Reviewer { + result := make([]Reviewer, 0, len(current)+len(requested)) + known := make(map[string]bool) + for _, reviewer := range current { + if reviewer.State == "REVIEW_REQUESTED" { + continue + } + result = append(result, reviewer) + known[strings.ToLower(reviewer.Login)] = true + } + for _, login := range requested { + if key := strings.ToLower(login); !known[key] { + result = append(result, Reviewer{Login: login, State: "REVIEW_REQUESTED"}) + known[key] = true + } + } + return result +} + +func blockedMutationChoices(operation mutationOperation) []string { + choices := []string{"Keep queued and retry after refresh"} + if operation.Kind == mutationPREdit { + choices = append(choices, "Review queued edit against current GitHub state") + } + if operation.Ambiguous { + choices = append(choices, "Retry this mutation now", "Treat this mutation as applied") + } + return append(choices, + "Discard this mutation and continue", + "Discard all queued mutations for this pull request", + ) +} + +func (m *App) resolveBlockedMutation(choice int) tea.Cmd { + if m.blockedMutation == nil || m.mutations == nil { + m.writeMode = writeNone + return nil + } + operation := *m.blockedMutation + label := blockedMutationChoices(operation)[choice] + m.writeMode, m.blockedMutation, m.err = writeNone, nil, nil + switch label { + case "Keep queued and retry after refresh": + return nil + case "Review queued edit against current GitHub state": + m.details = m.blockedMutationDetails + m.editingMutationID = operation.ID + command := m.startPREdit() + m.prEditEditors[prEditTitleField].Text = operation.Update.Title + m.prEditEditors[prEditBaseField].Text = operation.Update.BaseRef + m.prEditEditors[prEditReviewersField].Text = strings.Join(operation.Update.Reviewers, ", ") + m.prEditEditors[prEditAssigneesField].Text = strings.Join(operation.Update.Assignees, ", ") + m.prEditEditors[prEditBodyField].Text = operation.Update.Body + for index := range m.prEditEditors { + m.prEditEditors[index].Cursor = len([]rune(m.prEditEditors[index].Text)) + } + return command + case "Retry this mutation now": + operation.Attempted, operation.Blocked = false, false + operation.Ambiguous, operation.LastError = false, "" + if err := m.mutations.update(operation); err != nil { + m.err = err + return nil + } + return m.refreshAfterQueueChange(operation) + case "Treat this mutation as applied", "Discard this mutation and continue": + if err := m.mutations.remove(operation.ID); err != nil { + m.err = err + return nil + } + return m.startMutationReplay() + default: + if err := m.mutations.removePR(operation.Owner, operation.Repository, operation.Number); err != nil { + m.err = err + return nil + } + return m.refreshAfterQueueChange(operation) + } +} + +func (m *App) refreshAfterQueueChange(operation mutationOperation) tea.Cmd { + if m.details.Owner == operation.Owner && m.details.Repository == operation.Repository && + m.details.Number == operation.Number { + m.loading = true + return m.loadDetails(m.details.PullRequest, false) + } + return m.startMutationReplay() +} diff --git a/mutation_queue_test.go b/mutation_queue_test.go new file mode 100644 index 0000000..5bd81f9 --- /dev/null +++ b/mutation_queue_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestMutationQueuePersistsFIFOAndCapturedGates(t *testing.T) { + path := filepath.Join(t.TempDir(), "mutation-queue.json") + store := loadMutationQueue(path) + first := mutationOperation{ + ID: "first", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1, + ThreadID: "thread", Body: "reply", ThreadCanReply: true, + Permissions: ViewerPermissions{CanReplyAny: true}, EnqueuedAt: time.Now(), + } + second := mutationOperation{ + ID: "second", Kind: mutationResolution, Owner: "o", Repository: "r", Number: 1, + ThreadID: "thread", Resolved: true, ThreadCanResolve: true, EnqueuedAt: time.Now(), + } + if err := store.add(first); err != nil { + t.Fatal(err) + } + if err := store.add(second); err != nil { + t.Fatal(err) + } + reloaded := loadMutationQueue(path) + operations := reloaded.list() + if len(operations) != 2 || operations[0].ID != "first" || operations[1].ID != "second" || + !operations[0].ThreadCanReply || !operations[0].Permissions.CanReplyAny { + t.Fatalf("reloaded queue = %#v", operations) + } +} + +func TestCorruptMutationQueueRefusesToOverwriteUserData(t *testing.T) { + path := filepath.Join(t.TempDir(), "mutation-queue.json") + if err := atomicWriteJSON(path, map[string]any{"broken": true}, 0o600); err != nil { + t.Fatal(err) + } + store := loadMutationQueue(path) + if store.loadErr == nil { + t.Fatal("corrupt queue was accepted") + } + if err := store.add(mutationOperation{Kind: mutationReply}); err == nil { + t.Fatal("corrupt queue was overwritten by a new mutation") + } +} + +func TestQueuedMutationsProjectWithoutChangingSnapshot(t *testing.T) { + store := loadMutationQueue("") + now := time.Now() + for _, operation := range []mutationOperation{ + {ID: "reply", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1, + ThreadID: "thread", Body: "pending body", Viewer: "me", EnqueuedAt: now}, + {ID: "resolve", Kind: mutationResolution, Owner: "o", Repository: "r", Number: 1, + ThreadID: "thread", Resolved: true, EnqueuedAt: now.Add(time.Second)}, + {ID: "edit", Kind: mutationPREdit, Owner: "o", Repository: "r", Number: 1, + Update: PullRequestMetadata{Title: "queued", Body: "body", BaseRef: "next", + Reviewers: []string{"reviewer"}, Assignees: []string{"assignee"}}, EnqueuedAt: now}, + } { + if err := store.add(operation); err != nil { + t.Fatal(err) + } + } + m := NewApp(nil, "o", "r", false, 50, time.Minute) + m.mutations = store + snapshot := PRDetails{ + PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1, Title: "remote"}, + Threads: []ReviewThread{{ID: "thread"}}, + } + projected := m.projectQueuedMutations(snapshot) + 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 { + t.Fatalf("projection = %#v", projected) + } +} + +func TestCachedGrantedPermissionQueuesReply(t *testing.T) { + store := loadMutationQueue("") + settings := defaultAppSettings() + settings.Mutations = store + m := NewAppWithSettings(&recordingService{}, "o", "r", false, 50, time.Minute, settings) + m.loading, m.screen = false, threadScreen + m.details = PRDetails{ + PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}, FromCache: true, + ViewerLogin: "me", Permissions: ViewerPermissions{CanReplyAny: true}, + Threads: []ReviewThread{{ID: "thread", ViewerCanReply: true}}, + } + m.writeMode, m.writeThreadID, m.replyDraft = writeReplyBusy, "thread", "offline reply" + message := m.submitReply()() + 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 { + t.Fatalf("queued cached reply: count=%d mode=%d details=%#v command=%v", + store.count(), m.writeMode, m.details, command) + } +} + +func TestReplayChecksLivePermissionBeforeMutation(t *testing.T) { + service := &recordingService{} + store := loadMutationQueue("") + operation := mutationOperation{ + ID: "reply", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1, + ThreadID: "thread", Body: "body", Viewer: "me", ThreadCanReply: 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: false}}, + } + result := executeQueuedMutation(context.Background(), service, store, operation, details) + if result.state != mutationReplayBlocked || service.writeBody != "" || + !strings.Contains(result.reason, "no longer grants reply permission") { + t.Fatalf("permission replay result = %#v service=%#v", result, service) + } +} + +func TestReplayReconcilesReplyBeforeAskingAboutAmbiguousDelivery(t *testing.T) { + store := loadMutationQueue("") + enqueued := time.Now().Add(-time.Minute) + operation := mutationOperation{ + ID: "reply", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1, + ThreadID: "thread", Body: "body", Viewer: "me", Attempted: true, EnqueuedAt: enqueued, + } + details := PRDetails{Threads: []ReviewThread{{ + ID: "thread", ViewerCanReply: true, + Comments: []ReviewComment{{Author: "me", Body: "body", CreatedAt: enqueued.Add(time.Second)}}, + }}} + result := executeQueuedMutation(context.Background(), &recordingService{}, store, operation, details) + if result.state != mutationReplayApplied { + t.Fatalf("reconciled reply = %#v", result) + } + details.Threads[0].Comments = append(details.Threads[0].Comments, details.Threads[0].Comments[0]) + result = executeQueuedMutation(context.Background(), &recordingService{}, store, operation, details) + if result.state != mutationReplayBlocked || !result.operation.Ambiguous { + t.Fatalf("duplicate matching replies were not treated as ambiguous: %#v", result) + } + + details.Threads[0].Comments = nil + result = executeQueuedMutation(context.Background(), &recordingService{}, store, operation, details) + if result.state != mutationReplayBlocked || !result.operation.Ambiguous { + t.Fatalf("ambiguous reply = %#v", result) + } +} + +func TestQueuedPREditThreeWayMergeOnlyBlocksConflictingFields(t *testing.T) { + base := PullRequestMetadata{Title: "old", Body: "old body", BaseRef: "main"} + desired := base + desired.Title = "queued title" + remote := base + remote.Body = "remote body" + merged, conflict := rebaseQueuedPREdit(base, desired, remote) + if conflict != "" || merged.Title != "queued title" || merged.Body != "remote body" { + t.Fatalf("non-conflicting merge = %#v conflict=%q", merged, conflict) + } + remote.Title = "remote title" + _, conflict = rebaseQueuedPREdit(base, desired, remote) + if conflict != "title" { + t.Fatalf("conflict = %q, want title", conflict) + } +} + +func TestBlockedPREditCanBeReviewedAndReplacedInPlace(t *testing.T) { + store := loadMutationQueue("") + operation := mutationOperation{ + ID: "edit", Kind: mutationPREdit, Owner: "o", Repository: "r", Number: 1, PRID: "pr", + Original: PullRequestMetadata{Title: "old", Body: "body", BaseRef: "main"}, + Update: PullRequestMetadata{Title: "queued", Body: "body", BaseRef: "main"}, + Blocked: true, LastError: "title changed", EnqueuedAt: time.Now(), + } + if err := store.add(operation); err != nil { + t.Fatal(err) + } + settings := defaultAppSettings() + settings.Mutations = store + m := NewAppWithSettings(&recordingPRService{}, "o", "r", false, 50, time.Minute, settings) + m.loading, m.blockedMutation, m.writeMode = false, &operation, writeQueueBlocked + m.blockedMutationDetails = PRDetails{ + PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1, Title: "remote"}, + Body: "body", BaseRef: "main", Permissions: ViewerPermissions{CanUpdatePR: true}, + } + command := m.resolveBlockedMutation(1) + if command == nil || m.writeMode != writePREdit || m.editingMutationID != "edit" || + m.prEditEditors[prEditTitleField].Text != "queued" || m.prEditOriginal.Title != "remote" { + t.Fatalf("reviewed edit mode=%d id=%q title=%q original=%#v command=%v", + m.writeMode, m.editingMutationID, m.prEditEditors[prEditTitleField].Text, m.prEditOriginal, command) + } + m.prEditEditors[prEditTitleField].Text = "reconciled" + message := m.submitPREdit()() + if queued, ok := message.(mutationQueuedMsg); !ok || queued.err != nil { + t.Fatalf("replacement message = %#v", message) + } + replaced, ok := store.front() + if !ok || store.count() != 1 || replaced.ID != "edit" || replaced.Blocked || + replaced.Original.Title != "remote" || replaced.Update.Title != "reconciled" { + t.Fatalf("replaced operation = %#v", replaced) + } +} + +type failingReplyService struct{ recordingService } + +func (s *failingReplyService) ReplyToThread(context.Context, string, string) (ReviewComment, error) { + return ReviewComment{}, errors.New("connection lost") +} diff --git a/pr_editor.go b/pr_editor.go index d43eaa5..981b36a 100644 --- a/pr_editor.go +++ b/pr_editor.go @@ -100,8 +100,11 @@ func (m App) pullRequestUpdateUnavailable() string { if m.loading { return "pull request update unavailable while PR data is refreshing" } - if m.details.FromCache { - return "pull request update unavailable from an offline cached snapshot" + if m.details.FromCache && m.mutations == nil { + return "offline mutation queue is unavailable" + } + if m.mutations != nil && m.mutations.loadErr != nil { + return "mutation queue is unavailable: " + m.mutations.loadErr.Error() } if _, ok := m.service.(GitHubPullRequestWriteService); !ok { return "configured GitHub service does not support pull request updates" @@ -302,6 +305,21 @@ func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) { } func (m App) submitPREdit() tea.Cmd { + if m.mutations != nil { + operation := mutationOperation{ + Kind: mutationPREdit, Owner: m.details.Owner, Repository: m.details.Repository, + Number: m.details.Number, PRID: m.details.ID, Viewer: m.details.ViewerLogin, + Original: m.prEditOriginal, Update: m.prEditMetadata(), + Permissions: m.details.Permissions, + } + if m.editingMutationID != "" { + if existing, ok := m.mutations.get(m.editingMutationID); ok { + operation.ID, operation.EnqueuedAt = existing.ID, existing.EnqueuedAt + return m.replaceMutation(operation) + } + } + return m.enqueueMutation(operation) + } writer := m.service.(GitHubPullRequestWriteService) peopleWriter := m.service.(GitHubPullRequestPeopleWriteService) id := m.details.ID @@ -447,6 +465,7 @@ func (m *App) applyPREditPeople(people PullRequestPeople) { } func (m *App) clearPREdit() { + m.editingMutationID = "" m.prEditField = 0 m.prEditEditors = [prEditFieldCount]textEditor{} m.prEditOriginal = PullRequestMetadata{} diff --git a/tui.go b/tui.go index 9ee3195..ef9597e 100644 --- a/tui.go +++ b/tui.go @@ -52,6 +52,7 @@ const ( writeAutoMergeBusy writeMergeNowConfirm writeMergeNowBusy + writeQueueBlocked ) type threadResolvedMsg struct { @@ -209,6 +210,12 @@ type App struct { aiSpinner int aiEvents <-chan tea.Msg difflet diffletModel + mutations *mutationQueueStore + mutationReplayBusy bool + blockedMutation *mutationOperation + blockedMutationDetails PRDetails + blockedMutationChoice int + editingMutationID string } type AppSettings struct { @@ -225,6 +232,7 @@ type AppSettings struct { KeyBindings KeyBindings ReadState *readStateStore Drafts *draftStore + Mutations *mutationQueueStore AI *AIController AIStore *AIStore Mascot bool @@ -281,6 +289,7 @@ func NewAppWithSettings( keybindings: settings.KeyBindings, readState: state, drafts: settings.Drafts, + mutations: settings.Mutations, knownThreads: make(map[string]bool), knownComments: make(map[string]bool), initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool), unreadComments: make(map[string]bool), newThreads: make(map[string]bool), @@ -567,8 +576,11 @@ func (m App) writeActionUnavailable(action string, thread *ReviewThread) string if m.loading { return "write action unavailable while PR data is refreshing" } - if m.details.FromCache { - return "write action unavailable from an offline cached snapshot" + if m.details.FromCache && m.mutations == nil { + return "offline mutation queue is unavailable" + } + if m.mutations != nil && m.mutations.loadErr != nil { + return "mutation queue is unavailable: " + m.mutations.loadErr.Error() } if _, ok := m.service.(GitHubWriteService); !ok { return "configured GitHub service does not support write actions" @@ -596,6 +608,21 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { } k := m.keybindings.canonicalWriteKey(key.String()) switch m.writeMode { + case writeQueueBlocked: + choices := blockedMutationChoices(*m.blockedMutation) + switch { + case keyMatches(key.String(), m.keybindings.Navigation.Down): + m.blockedMutationChoice = min(m.blockedMutationChoice+1, len(choices)-1) + case keyMatches(key.String(), m.keybindings.Navigation.Up): + m.blockedMutationChoice = max(0, m.blockedMutationChoice-1) + case keyMatches(key.String(), m.keybindings.General.Confirm), + keyMatches(key.String(), m.keybindings.Views.Open): + return m, m.resolveBlockedMutation(m.blockedMutationChoice) + case keyMatches(key.String(), m.keybindings.Input.Cancel), + keyMatches(key.String(), m.keybindings.General.Reject): + m.writeMode, m.blockedMutation, m.err = writeNone, nil, nil + } + return m, nil case writeReply: switch k { case "esc": @@ -663,6 +690,19 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m App) submitReply() tea.Cmd { + if m.mutations != nil { + thread := m.threadByID(m.writeThreadID) + operation := mutationOperation{ + Kind: mutationReply, Owner: m.details.Owner, Repository: m.details.Repository, + Number: m.details.Number, PRID: m.details.ID, ThreadID: m.writeThreadID, + Body: strings.TrimRight(m.replyDraft, "\n"), Viewer: m.details.ViewerLogin, + Permissions: m.details.Permissions, + } + if thread != nil { + operation.ThreadCanReply = thread.ViewerCanReply + } + return m.enqueueMutation(operation) + } writer := m.service.(GitHubWriteService) threadID, body := m.writeThreadID, strings.TrimRight(m.replyDraft, "\n") return func() tea.Msg { @@ -683,6 +723,20 @@ func (m App) submitResolution() tea.Cmd { return threadResolvedMsg{threadID: threadID, thread: thread, err: err} } } + if m.mutations != nil { + thread := m.threadByID(m.writeThreadID) + operation := mutationOperation{ + Kind: mutationResolution, Owner: m.details.Owner, Repository: m.details.Repository, + Number: m.details.Number, PRID: m.details.ID, ThreadID: m.writeThreadID, + Resolved: m.resolveTarget, Viewer: m.details.ViewerLogin, + Permissions: m.details.Permissions, + } + if thread != nil { + operation.ThreadCanResolve = thread.ViewerCanResolve + operation.ThreadCanUnresolve = thread.ViewerCanUnresolve + } + return m.enqueueMutation(operation) + } writer := m.service.(GitHubWriteService) threadID, resolved := m.writeThreadID, m.resolveTarget return func() tea.Msg { @@ -781,7 +835,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.prs) > 0 && m.prIndex < len(m.prs) { selected = m.prs[m.prIndex].ID } - m.prs = msg.prs + m.prs = m.projectQueuedPullRequests(msg.prs) sort.SliceStable(m.prs, func(i, j int) bool { left, right := strings.ToLower(m.prs[i].RepoWithOwner), strings.ToLower(m.prs[j].RepoWithOwner) if left != right { @@ -797,9 +851,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lastRefresh = time.Now() } if len(m.prs) == 0 { - return m, m.difflet.setState(diffletSleeping) + return m, tea.Batch(m.difflet.setState(diffletSleeping), m.startMutationReplay()) } - return m, m.difflet.setState(diffletIdle) + return m, tea.Batch(m.difflet.setState(diffletIdle), m.startMutationReplay()) case detailsLoadedMsg: if m.requests != nil && !m.requests.current(msg.requestID) { return m, nil @@ -844,6 +898,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.trackThreadUpdates(msg.details) + msg.details = m.projectQueuedMutations(msg.details) sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus) m.details = msg.details m.err = nil @@ -883,9 +938,12 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if _, ok := m.service.(GitHubEnrichmentService); ok { m.secondaryLoading = true diffletCommand = m.difflet.setState(diffletLoading) - return m, tea.Batch(m.loadDetailsEnrichment(msg.details), diffletCommand) + return m, tea.Batch(m.loadDetailsEnrichment(msg.details), diffletCommand, m.startMutationReplay()) } } + if !msg.cached { + return m, tea.Batch(diffletCommand, m.startMutationReplay()) + } return m, diffletCommand case detailsEnrichedMsg: if m.requests != nil && !m.requests.current(msg.requestID) { @@ -923,6 +981,73 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.recordHealth("draft persistence", healthWarning, msg.err.Error()) } + case mutationQueuedMsg: + if msg.err != nil { + m.err = fmt.Errorf("queue mutation: %w", msg.err) + m.recordHealth("mutation queue", healthError, msg.err.Error()) + switch msg.operation.Kind { + case mutationReply: + m.writeMode = writeReply + case mutationResolution: + m.writeMode = writeNone + case mutationPREdit: + m.writeMode = writePREdit + } + return m, m.difflet.setState(diffletRecoverableError) + } + m.details = m.projectQueuedMutations(m.details) + m.err = nil + switch msg.operation.Kind { + case mutationReply: + _ = m.drafts.delete(replyDraftKey( + msg.operation.Owner, msg.operation.Repository, msg.operation.Number, msg.operation.ThreadID, + )) + m.replyDraft, m.replyEditor, m.writeThreadID = "", textEditor{}, "" + case mutationResolution: + m.writeThreadID = "" + case mutationPREdit: + _ = m.drafts.delete(prMetadataDraftKey( + msg.operation.Owner, msg.operation.Repository, msg.operation.Number, + )) + m.clearPREdit() + } + m.writeMode = writeNone + if m.details.FromCache { + return m, m.difflet.setState(diffletIdle) + } + return m, tea.Batch(m.startMutationReplay(), m.difflet.setState(diffletLoading)) + 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) + } + if msg.state == mutationReplayBlocked { + m.blockedMutation = &msg.operation + m.blockedMutationDetails = msg.details + m.blockedMutationChoice = 0 + m.writeMode = writeQueueBlocked + m.err = errors.New(msg.reason) + m.recordHealth("mutation replay", healthWarning, msg.reason) + return m, m.difflet.setState(diffletRecoverableError) + } + if err := m.mutations.remove(msg.operation.ID); err != nil { + m.err = fmt.Errorf("finish queued mutation: %w", err) + m.recordHealth("mutation queue", healthError, err.Error()) + return m, m.difflet.setState(diffletRecoverableError) + } + 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, tea.Batch(m.startMutationReplay(), m.difflet.setState(diffletSuccess)) case branchesLoadedMsg: if m.writeMode != writePREdit || msg.owner != m.details.Owner || msg.repo != m.details.Repository { @@ -2283,6 +2408,27 @@ func (m App) viewWritePopup() string { lines = m.prEditConfirmationLines(width - 2) case writePREditBusy: lines = []string{titleStyle.Render("Updating pull request…")} + case writeQueueBlocked: + operation := *m.blockedMutation + lines = []string{ + titleStyle.Render("Queued mutation needs attention"), "", + warnStyle.Render(operation.LastError), "", + } + for index, choice := range blockedMutationChoices(operation) { + prefix := " " + if index == m.blockedMutationChoice { + prefix = "› " + choice = activeStyle.Render(choice) + } + lines = append(lines, prefix+choice) + } + lines = append(lines, "", dimStyle.Render(fmt.Sprintf( + "%s/%s select • %s apply • %s dismiss", + primaryKeyLabel(m.keybindings.Navigation.Down), + primaryKeyLabel(m.keybindings.Navigation.Up), + primaryCombinedKeyLabel(m.keybindings.General.Confirm, m.keybindings.Views.Open), + primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), + ))) } maxLines := max(3, m.height-4) if len(lines) > maxLines { @@ -2623,6 +2769,9 @@ func (m App) viewPRs() string { if pr.FromCache { draft += " CACHED" } + if pr.Pending { + draft += " PENDING" + } titleWidth := max(10, m.width-36) line := fmt.Sprintf(" #%-5d %-*s %2d threads%s", pr.Number, titleWidth, truncate(pr.Title, titleWidth), pr.ReviewCount, draft) if row.prIndex == m.prIndex { @@ -2914,6 +3063,26 @@ func (m App) healthLines() []string { } components = append(components, component) } + if m.mutations != nil { + count := m.mutations.count() + component := HealthComponent{ + Name: "mutation queue", Level: healthOK, + Summary: fmt.Sprintf("%d queued mutation(s)", count), Detail: m.mutations.path, + } + if m.mutations.loadErr != nil { + component.Level = healthError + component.Summary = "mutation queue could not be loaded safely" + component.Detail = m.mutations.loadErr.Error() + } else if operation, ok := m.mutations.front(); ok && operation.Blocked { + component.Level = healthWarning + component.Summary = "queued replay is paused" + component.Detail = operation.LastError + } else if count > 0 { + component.Level = healthInfo + component.Summary = fmt.Sprintf("%d mutation(s) waiting for ordered replay", count) + } + components = append(components, component) + } if m.ai == nil || !m.ai.config.Enabled { components = append(components, HealthComponent{ Name: "AI integration", Level: healthInfo, @@ -3058,6 +3227,9 @@ func (m App) dashboardHeaderLines() []string { "OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"), )) } + if count := m.mutations.count(); count > 0 { + lines = append(lines, warnStyle.Render(fmt.Sprintf("%d queued mutation(s) pending", count))) + } if len(pr.DataIssues) > 0 { lines = append(lines, warnStyle.Render(fmt.Sprintf( "PARTIAL DATA • %d subsection(s) unavailable; press %s for details", @@ -3391,14 +3563,6 @@ type writeCapability struct { } func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability { - if pr.FromCache { - reason := "offline cached snapshot" - return []writeCapability{ - {name: "reply", reason: reason}, {name: "resolve", reason: reason}, - {name: "react", reason: reason}, {name: "update pull request", reason: reason}, - {name: "auto-merge", reason: reason}, {name: "merge now", reason: reason}, - } - } threadReason := "select a review thread" canReply, canResolve := false, false resolveName := "resolve thread" @@ -3418,14 +3582,23 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability { if pr.Merged || pr.State == "CLOSED" { autoMergeReason = "pull request is already closed" } + if pr.FromCache { + threadReason = "saved snapshot did not grant this thread permission" + autoMergeAllowed = false + autoMergeReason = "auto-merge remains online-only" + } + mergeAllowed := !pr.FromCache && mergeNowStateReason(pr) == "" + mergeReason := firstNonEmpty(mergeNowStateReason(pr), "available") + if pr.FromCache { + mergeReason = "merge remains online-only" + } return []writeCapability{ capability("reply", canReply, threadReason, true), capability(resolveName, canResolve, threadReason, true), - capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false), + capability("react", pr.Permissions.CanReact && !pr.FromCache, "reactions remain online-only", false), capability("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true), capability("auto-merge", autoMergeAllowed, autoMergeReason, true), - capability("merge now", mergeNowStateReason(pr) == "", - firstNonEmpty(mergeNowStateReason(pr), "available"), true), + capability("merge now", mergeAllowed, mergeReason, true), } } @@ -3498,6 +3671,9 @@ func (m App) threadTopLines() []string { " • refreshing live data", )) } + if count := m.mutations.count(); count > 0 { + top = append(top, warnStyle.Render(fmt.Sprintf("%d queued mutation(s) pending", count))) + } if pr.ThreadsTruncated { top = append(top, warnStyle.Render("Showing the first 100 review threads.")) } @@ -3692,6 +3868,9 @@ func (m App) detailLines(width int) []detailLine { if thread.Origin == reviewOriginLocalAI { status += ", LOCAL AI · LOCAL ONLY" } + if thread.Pending { + status += ", PENDING" + } if m.unreadThreads[thread.ID] { if m.newThreads[thread.ID] { status += ", new thread" diff --git a/tui_test.go b/tui_test.go index 31ad0fe..6ac395d 100644 --- a/tui_test.go +++ b/tui_test.go @@ -585,9 +585,17 @@ func TestDetailRefreshKeepsLogicalCommentAnchored(t *testing.T) { func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) { cached := writeCapabilities(PRDetails{FromCache: true}, nil) - if cached[0].reason != "offline cached snapshot" || cached[0].enabled { + if cached[0].reason != "saved snapshot did not grant this thread permission" || cached[0].enabled { t.Fatalf("cached capability = %#v", cached[0]) } + cachedAllowed := writeCapabilities(PRDetails{ + PullRequest: PullRequest{FromCache: true}, + Permissions: ViewerPermissions{CanUpdatePR: true}, + }, &ReviewThread{ViewerCanReply: true, ViewerCanResolve: true}) + if !cachedAllowed[0].enabled || !cachedAllowed[1].enabled || !cachedAllowed[3].enabled || + cachedAllowed[4].enabled || cachedAllowed[5].enabled { + t.Fatalf("cached saved gates = %#v", cachedAllowed) + } thread := &ReviewThread{ViewerCanReply: true} live := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanReact: true}}, thread) if !live[0].enabled || live[1].enabled || live[2].enabled { diff --git a/types.go b/types.go index 655eb34..8952c4d 100644 --- a/types.go +++ b/types.go @@ -17,6 +17,7 @@ type PullRequest struct { ViewerAuthored bool FromCache bool CachedAt time.Time + Pending bool `json:"-"` } type PRDetails struct { @@ -242,6 +243,7 @@ type ReviewThread struct { Model string HeadOID string Fingerprint string + Pending bool `json:"-"` } type ReviewComment struct { @@ -261,6 +263,7 @@ type ReviewComment struct { Origin string Provider string Model string + Pending bool `json:"-"` } const ( diff --git a/version.go b/version.go index 5417881..8be30ba 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const dipleVersion = "0.3.4" +const dipleVersion = "0.4.0"