feat: queued mutations while reloading or offline

This commit is contained in:
2026-08-03 12:14:31 +02:00
parent 63b6a645e0
commit 312b25fd39
11 changed files with 1247 additions and 28 deletions

View File

@@ -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. The UI explains unavailable actions through its write-capability gate.
Metadata and reply drafts are persisted locally so cancellation or a restart 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 Reactions are currently read-only. Assigning labels or milestones is not
implemented yet. implemented yet.
@@ -521,9 +525,22 @@ Cached data is labelled when first shown. A normal refresh does not repeatedly
reintroduce the cached header. reintroduce the cached header.
Read state and recoverable drafts live beside the configuration file as Read state and recoverable drafts live beside the configuration file as
`state.json` and `drafts.json`. Experimental AI data defaults to the `ai` `state.json` and `drafts.json`. Confirmed reversible GitHub writes are kept in
directory beside the configuration. These files are versioned and written `mutation-queue.json` until GitHub confirms them. Experimental AI data defaults
atomically; sensitive user-authored state uses restrictive permissions. 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 ## Experimental local AI review

View File

@@ -2,9 +2,9 @@
This list reflects the current implementation: paginated review threads, This list reflects the current implementation: paginated review threads,
thread comments, conversation comments, reviews, timeline events, checks, and thread comments, conversation comments, reviews, timeline events, checks, and
annotations; cached read-only snapshots; persistent unread state; contextual annotations; cached snapshots with durable ordered offline writes; persistent
keybindings; thread replies and resolution changes; and pull-request metadata unread state; contextual keybindings; thread replies and resolution changes;
editing are already implemented. and pull-request metadata editing are already implemented.
## Experimental AI follow-up ## Experimental AI follow-up

View File

@@ -658,6 +658,9 @@ func renderAIProgressBar(width int, progress AIRunProgress, spinner int) string
} }
func localAICommentBadge(comment ReviewComment) string { func localAICommentBadge(comment ReviewComment) string {
if comment.Pending {
return " " + warnStyle.Render("[PENDING]")
}
switch comment.Origin { switch comment.Origin {
case reviewOriginLocalAI: case reviewOriginLocalAI:
return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]") return " " + warnStyle.Render("[LOCAL AI · LOCAL ONLY]")

View File

@@ -155,6 +155,7 @@ func main() {
} }
statePath := filepath.Join(filepath.Dir(*configFile), "state.json") statePath := filepath.Join(filepath.Dir(*configFile), "state.json")
draftPath := filepath.Join(filepath.Dir(*configFile), "drafts.json") draftPath := filepath.Join(filepath.Dir(*configFile), "drafts.json")
mutationQueuePath := filepath.Join(filepath.Dir(*configFile), "mutation-queue.json")
var aiController *AIController var aiController *AIController
var aiStore *AIStore var aiStore *AIStore
if config.AI.Enabled { if config.AI.Enabled {
@@ -189,6 +190,7 @@ func main() {
ViewerLabel: config.Display.ViewerLabel, ViewerLabel: config.Display.ViewerLabel,
ReadState: loadReadState(statePath), ReadState: loadReadState(statePath),
Drafts: loadDraftStore(draftPath), Drafts: loadDraftStore(draftPath),
Mutations: loadMutationQueue(mutationQueuePath),
PathScroll: config.Paths.Scroll, PathScroll: config.Paths.Scroll,
PathScrollInterval: config.Paths.ScrollInterval.Duration, PathScrollInterval: config.Paths.ScrollInterval.Duration,
ThreadStatusOrder: config.Threads.StatusOrder, ThreadStatusOrder: config.Threads.StatusOrder,

624
mutation_queue.go Normal file
View File

@@ -0,0 +1,624 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"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 {
if reflect.DeepEqual(s.operations[index], operation) {
return nil
}
previous := s.operations[index]
s.operations[index] = operation
if err := s.flushLocked(); err != nil {
s.operations[index] = previous
return err
}
return nil
}
}
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 {
previous := slices.Clone(s.operations)
s.operations = append(s.operations[:index], s.operations[index+1:]...)
if err := s.flushLocked(); err != nil {
s.operations = previous
return err
}
return nil
}
}
return nil
}
func (s *mutationQueueStore) removePR(owner, repo string, number int) error {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
previous := slices.Clone(s.operations)
filtered := make([]mutationOperation, 0, len(s.operations))
removed := false
for _, operation := range s.operations {
if operation.Owner != owner || operation.Repository != repo || operation.Number != number {
filtered = append(filtered, operation)
} else {
removed = true
}
}
if !removed {
return nil
}
s.operations = filtered
if err := s.flushLocked(); err != nil {
s.operations = previous
return err
}
return nil
}
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
if err := store.update(operation); err != nil {
operation.Attempted = false
return mutationReplayMsg{
operation: operation, state: mutationReplayBlocked, details: details,
reason: "could not checkpoint the reply attempt", err: err,
}
}
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}
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
if err := store.update(operation); err != nil {
operation.Attempted = false
return mutationReplayMsg{
operation: operation, state: mutationReplayBlocked, details: details,
reason: "could not checkpoint the thread update attempt", err: err,
}
}
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}
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 {
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 {
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()
}

364
mutation_queue_test.go Normal file
View File

@@ -0,0 +1,364 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func failingMutationQueuePath(t *testing.T) string {
t.Helper()
blocker := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil {
t.Fatal(err)
}
return filepath.Join(blocker, "mutation-queue.json")
}
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 TestMutationQueueRollsBackMemoryWhenPersistenceFails(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}
second := mutationOperation{ID: "second", Kind: mutationResolution, Owner: "o", Repository: "r", Number: 2}
if err := store.add(first); err != nil {
t.Fatal(err)
}
if err := store.add(second); err != nil {
t.Fatal(err)
}
store.path = failingMutationQueuePath(t)
changed := first
changed.Attempted = true
if err := store.update(changed); err == nil {
t.Fatal("update unexpectedly succeeded")
}
if got, _ := store.get("first"); got.Attempted {
t.Fatalf("failed update remained in memory: %#v", got)
}
if err := store.remove("first"); err == nil {
t.Fatal("remove unexpectedly succeeded")
}
if operations := store.list(); len(operations) != 2 || operations[0].ID != "first" || operations[1].ID != "second" {
t.Fatalf("failed remove changed memory: %#v", operations)
}
if err := store.removePR("o", "r", 1); err == nil {
t.Fatal("removePR unexpectedly succeeded")
}
if operations := store.list(); len(operations) != 2 || operations[0].ID != "first" || operations[1].ID != "second" {
t.Fatalf("failed removePR changed memory: %#v", operations)
}
reloaded := loadMutationQueue(path)
if operations := reloaded.list(); len(operations) != 2 || operations[0].ID != "first" || operations[1].ID != "second" {
t.Fatalf("durable queue changed after failed writes: %#v", operations)
}
}
func TestMutationQueueSkipsPersistenceForNoOpChanges(t *testing.T) {
path := filepath.Join(t.TempDir(), "mutation-queue.json")
store := loadMutationQueue(path)
operation := mutationOperation{
ID: "first", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1,
ThreadID: "thread", Body: "body", EnqueuedAt: time.Now(),
}
if err := store.add(operation); err != nil {
t.Fatal(err)
}
store.path = failingMutationQueuePath(t)
if err := store.update(operation); err != nil {
t.Fatalf("identical update attempted persistence: %v", err)
}
if err := store.removePR("different", "repository", 99); err != nil {
t.Fatalf("non-matching removePR attempted persistence: %v", err)
}
if stored, ok := store.front(); !ok || !reflect.DeepEqual(stored, operation) {
t.Fatalf("no-op changes altered the queue: %#v", stored)
}
}
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 TestReplyIsNotSentUnlessAttemptCheckpointIsDurable(t *testing.T) {
path := filepath.Join(t.TempDir(), "mutation-queue.json")
store := loadMutationQueue(path)
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)
}
store.path = failingMutationQueuePath(t)
service := &recordingService{}
details := PRDetails{Threads: []ReviewThread{{ID: "thread", ViewerCanReply: true}}}
result := executeQueuedMutation(context.Background(), service, store, operation, details)
if result.state != mutationReplayBlocked || result.err == nil ||
result.reason != "could not checkpoint the reply attempt" || service.writeBody != "" {
t.Fatalf("reply checkpoint result=%#v service=%#v", result, service)
}
stored, _ := store.front()
if stored.Attempted {
t.Fatalf("failed attempt checkpoint remained in memory: %#v", stored)
}
}
func TestRepeatedBlockedReplayDoesNotRewriteSnapshot(t *testing.T) {
path := filepath.Join(t.TempDir(), "mutation-queue.json")
store := loadMutationQueue(path)
operation := mutationOperation{
ID: "reply", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1,
ThreadID: "thread", Body: "body", Viewer: "me", Attempted: true, EnqueuedAt: time.Now(),
}
if err := store.add(operation); err != nil {
t.Fatal(err)
}
details := PRDetails{Threads: []ReviewThread{{ID: "thread", ViewerCanReply: true}}}
first := executeQueuedMutation(context.Background(), &recordingService{}, store, operation, details)
if first.state != mutationReplayBlocked || first.err != nil {
t.Fatalf("initial blocked replay = %#v", first)
}
blocked, _ := store.front()
if !blocked.Blocked || !blocked.Ambiguous {
t.Fatalf("blocked state was not persisted: %#v", blocked)
}
store.path = failingMutationQueuePath(t)
repeated := executeQueuedMutation(context.Background(), &recordingService{}, store, blocked, details)
if repeated.state != mutationReplayBlocked || repeated.err != nil {
t.Fatalf("identical blocked replay attempted persistence: %#v", repeated)
}
}
func TestResolutionIsNotSentUnlessAttemptCheckpointIsDurable(t *testing.T) {
path := filepath.Join(t.TempDir(), "mutation-queue.json")
store := loadMutationQueue(path)
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)
}
store.path = failingMutationQueuePath(t)
service := &recordingService{}
details := PRDetails{Threads: []ReviewThread{{ID: "thread", ViewerCanResolve: true}}}
result := executeQueuedMutation(context.Background(), service, store, operation, details)
if result.state != mutationReplayBlocked || result.err == nil ||
result.reason != "could not checkpoint the thread update attempt" || service.writeThreadID != "" {
t.Fatalf("resolution checkpoint result=%#v service=%#v", result, service)
}
stored, _ := store.front()
if stored.Attempted {
t.Fatalf("failed attempt checkpoint remained in memory: %#v", stored)
}
}
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")
}

View File

@@ -100,8 +100,11 @@ func (m App) pullRequestUpdateUnavailable() string {
if m.loading { if m.loading {
return "pull request update unavailable while PR data is refreshing" return "pull request update unavailable while PR data is refreshing"
} }
if m.details.FromCache { if m.details.FromCache && m.mutations == nil {
return "pull request update unavailable from an offline cached snapshot" 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 { if _, ok := m.service.(GitHubPullRequestWriteService); !ok {
return "configured GitHub service does not support pull request updates" 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 { 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) writer := m.service.(GitHubPullRequestWriteService)
peopleWriter := m.service.(GitHubPullRequestPeopleWriteService) peopleWriter := m.service.(GitHubPullRequestPeopleWriteService)
id := m.details.ID id := m.details.ID
@@ -447,6 +465,7 @@ func (m *App) applyPREditPeople(people PullRequestPeople) {
} }
func (m *App) clearPREdit() { func (m *App) clearPREdit() {
m.editingMutationID = ""
m.prEditField = 0 m.prEditField = 0
m.prEditEditors = [prEditFieldCount]textEditor{} m.prEditEditors = [prEditFieldCount]textEditor{}
m.prEditOriginal = PullRequestMetadata{} m.prEditOriginal = PullRequestMetadata{}

213
tui.go
View File

@@ -52,6 +52,7 @@ const (
writeAutoMergeBusy writeAutoMergeBusy
writeMergeNowConfirm writeMergeNowConfirm
writeMergeNowBusy writeMergeNowBusy
writeQueueBlocked
) )
type threadResolvedMsg struct { type threadResolvedMsg struct {
@@ -209,6 +210,12 @@ type App struct {
aiSpinner int aiSpinner int
aiEvents <-chan tea.Msg aiEvents <-chan tea.Msg
difflet diffletModel difflet diffletModel
mutations *mutationQueueStore
mutationReplayBusy bool
blockedMutation *mutationOperation
blockedMutationDetails PRDetails
blockedMutationChoice int
editingMutationID string
} }
type AppSettings struct { type AppSettings struct {
@@ -225,6 +232,7 @@ type AppSettings struct {
KeyBindings KeyBindings KeyBindings KeyBindings
ReadState *readStateStore ReadState *readStateStore
Drafts *draftStore Drafts *draftStore
Mutations *mutationQueueStore
AI *AIController AI *AIController
AIStore *AIStore AIStore *AIStore
Mascot bool Mascot bool
@@ -281,6 +289,7 @@ func NewAppWithSettings(
keybindings: settings.KeyBindings, keybindings: settings.KeyBindings,
readState: state, readState: state,
drafts: settings.Drafts, drafts: settings.Drafts,
mutations: settings.Mutations,
knownThreads: make(map[string]bool), knownComments: make(map[string]bool), knownThreads: make(map[string]bool), knownComments: make(map[string]bool),
initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool), initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool),
unreadComments: make(map[string]bool), newThreads: 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 { if m.loading {
return "write action unavailable while PR data is refreshing" return "write action unavailable while PR data is refreshing"
} }
if m.details.FromCache { if m.details.FromCache && m.mutations == nil {
return "write action unavailable from an offline cached snapshot" 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 { if _, ok := m.service.(GitHubWriteService); !ok {
return "configured GitHub service does not support write actions" 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()) k := m.keybindings.canonicalWriteKey(key.String())
switch m.writeMode { 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: case writeReply:
switch k { switch k {
case "esc": case "esc":
@@ -663,6 +690,19 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
func (m App) submitReply() 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) writer := m.service.(GitHubWriteService)
threadID, body := m.writeThreadID, strings.TrimRight(m.replyDraft, "\n") threadID, body := m.writeThreadID, strings.TrimRight(m.replyDraft, "\n")
return func() tea.Msg { return func() tea.Msg {
@@ -683,6 +723,20 @@ func (m App) submitResolution() tea.Cmd {
return threadResolvedMsg{threadID: threadID, thread: thread, err: err} 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) writer := m.service.(GitHubWriteService)
threadID, resolved := m.writeThreadID, m.resolveTarget threadID, resolved := m.writeThreadID, m.resolveTarget
return func() tea.Msg { 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) { if len(m.prs) > 0 && m.prIndex < len(m.prs) {
selected = m.prs[m.prIndex].ID 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 { sort.SliceStable(m.prs, func(i, j int) bool {
left, right := strings.ToLower(m.prs[i].RepoWithOwner), strings.ToLower(m.prs[j].RepoWithOwner) left, right := strings.ToLower(m.prs[i].RepoWithOwner), strings.ToLower(m.prs[j].RepoWithOwner)
if left != right { if left != right {
@@ -797,9 +851,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
} }
if len(m.prs) == 0 { 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: case detailsLoadedMsg:
if m.requests != nil && !m.requests.current(msg.requestID) { if m.requests != nil && !m.requests.current(msg.requestID) {
return m, nil return m, nil
@@ -844,6 +898,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
m.trackThreadUpdates(msg.details) m.trackThreadUpdates(msg.details)
msg.details = m.projectQueuedMutations(msg.details)
sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus) sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
m.details = msg.details m.details = msg.details
m.err = nil m.err = nil
@@ -883,9 +938,12 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := m.service.(GitHubEnrichmentService); ok { if _, ok := m.service.(GitHubEnrichmentService); ok {
m.secondaryLoading = true m.secondaryLoading = true
diffletCommand = m.difflet.setState(diffletLoading) 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 return m, diffletCommand
case detailsEnrichedMsg: case detailsEnrichedMsg:
if m.requests != nil && !m.requests.current(msg.requestID) { 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 { if msg.err != nil {
m.recordHealth("draft persistence", healthWarning, msg.err.Error()) 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: case branchesLoadedMsg:
if m.writeMode != writePREdit || if m.writeMode != writePREdit ||
msg.owner != m.details.Owner || msg.repo != m.details.Repository { msg.owner != m.details.Owner || msg.repo != m.details.Repository {
@@ -2283,6 +2408,27 @@ func (m App) viewWritePopup() string {
lines = m.prEditConfirmationLines(width - 2) lines = m.prEditConfirmationLines(width - 2)
case writePREditBusy: case writePREditBusy:
lines = []string{titleStyle.Render("Updating pull request…")} 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) maxLines := max(3, m.height-4)
if len(lines) > maxLines { if len(lines) > maxLines {
@@ -2623,6 +2769,9 @@ func (m App) viewPRs() string {
if pr.FromCache { if pr.FromCache {
draft += " CACHED" draft += " CACHED"
} }
if pr.Pending {
draft += " PENDING"
}
titleWidth := max(10, m.width-36) titleWidth := max(10, m.width-36)
line := fmt.Sprintf(" #%-5d %-*s %2d threads%s", pr.Number, titleWidth, truncate(pr.Title, titleWidth), pr.ReviewCount, draft) line := fmt.Sprintf(" #%-5d %-*s %2d threads%s", pr.Number, titleWidth, truncate(pr.Title, titleWidth), pr.ReviewCount, draft)
if row.prIndex == m.prIndex { if row.prIndex == m.prIndex {
@@ -2914,6 +3063,26 @@ func (m App) healthLines() []string {
} }
components = append(components, component) 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 { if m.ai == nil || !m.ai.config.Enabled {
components = append(components, HealthComponent{ components = append(components, HealthComponent{
Name: "AI integration", Level: healthInfo, 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"), "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 { if len(pr.DataIssues) > 0 {
lines = append(lines, warnStyle.Render(fmt.Sprintf( lines = append(lines, warnStyle.Render(fmt.Sprintf(
"PARTIAL DATA • %d subsection(s) unavailable; press %s for details", "PARTIAL DATA • %d subsection(s) unavailable; press %s for details",
@@ -3391,14 +3563,6 @@ type writeCapability struct {
} }
func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability { 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" threadReason := "select a review thread"
canReply, canResolve := false, false canReply, canResolve := false, false
resolveName := "resolve thread" resolveName := "resolve thread"
@@ -3418,14 +3582,23 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
if pr.Merged || pr.State == "CLOSED" { if pr.Merged || pr.State == "CLOSED" {
autoMergeReason = "pull request is already 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{ return []writeCapability{
capability("reply", canReply, threadReason, true), capability("reply", canReply, threadReason, true),
capability(resolveName, canResolve, 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("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true),
capability("auto-merge", autoMergeAllowed, autoMergeReason, true), capability("auto-merge", autoMergeAllowed, autoMergeReason, true),
capability("merge now", mergeNowStateReason(pr) == "", capability("merge now", mergeAllowed, mergeReason, true),
firstNonEmpty(mergeNowStateReason(pr), "available"), true),
} }
} }
@@ -3498,6 +3671,9 @@ func (m App) threadTopLines() []string {
" • refreshing live data", " • 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 { if pr.ThreadsTruncated {
top = append(top, warnStyle.Render("Showing the first 100 review threads.")) 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 { if thread.Origin == reviewOriginLocalAI {
status += ", LOCAL AI · LOCAL ONLY" status += ", LOCAL AI · LOCAL ONLY"
} }
if thread.Pending {
status += ", PENDING"
}
if m.unreadThreads[thread.ID] { if m.unreadThreads[thread.ID] {
if m.newThreads[thread.ID] { if m.newThreads[thread.ID] {
status += ", new thread" status += ", new thread"

View File

@@ -585,9 +585,17 @@ func TestDetailRefreshKeepsLogicalCommentAnchored(t *testing.T) {
func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) { func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) {
cached := writeCapabilities(PRDetails{FromCache: true}, nil) 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]) 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} thread := &ReviewThread{ViewerCanReply: true}
live := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanReact: true}}, thread) live := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanReact: true}}, thread)
if !live[0].enabled || live[1].enabled || live[2].enabled { if !live[0].enabled || live[1].enabled || live[2].enabled {

View File

@@ -17,6 +17,7 @@ type PullRequest struct {
ViewerAuthored bool ViewerAuthored bool
FromCache bool FromCache bool
CachedAt time.Time CachedAt time.Time
Pending bool `json:"-"`
} }
type PRDetails struct { type PRDetails struct {
@@ -242,6 +243,7 @@ type ReviewThread struct {
Model string Model string
HeadOID string HeadOID string
Fingerprint string Fingerprint string
Pending bool `json:"-"`
} }
type ReviewComment struct { type ReviewComment struct {
@@ -261,6 +263,7 @@ type ReviewComment struct {
Origin string Origin string
Provider string Provider string
Model string Model string
Pending bool `json:"-"`
} }
const ( const (

View File

@@ -1,3 +1,3 @@
package main package main
const dipleVersion = "0.3.4" const dipleVersion = "0.4.0"