fix: own comments trigger NEW

This commit is contained in:
2026-08-03 15:27:57 +02:00
parent 6f963c7660
commit 63ce0319f7
4 changed files with 130 additions and 4 deletions

View File

@@ -35,6 +35,7 @@ type mutationOperation struct {
PRID string `json:"pull_request_id"` PRID string `json:"pull_request_id"`
ThreadID string `json:"thread_id,omitempty"` ThreadID string `json:"thread_id,omitempty"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
ReplyID string `json:"reply_id,omitempty"`
Resolved bool `json:"resolved,omitempty"` Resolved bool `json:"resolved,omitempty"`
Viewer string `json:"viewer,omitempty"` Viewer string `json:"viewer,omitempty"`
Original PullRequestMetadata `json:"original,omitempty"` Original PullRequestMetadata `json:"original,omitempty"`
@@ -372,9 +373,11 @@ func executeQueuedMutation(
reason: "could not checkpoint the reply attempt", err: err, reason: "could not checkpoint the reply attempt", err: err,
} }
} }
if _, err := writer.ReplyToThread(ctx, operation.ThreadID, operation.Body); err != nil { comment, err := writer.ReplyToThread(ctx, operation.ThreadID, operation.Body)
if err != nil {
return mutationReplayMsg{operation: operation, state: mutationReplayWaiting, details: details, err: err} return mutationReplayMsg{operation: operation, state: mutationReplayWaiting, details: details, err: err}
} }
operation.ReplyID = comment.ID
return verifying() return verifying()
case mutationResolution: case mutationResolution:
if thread == nil { if thread == nil {
@@ -477,6 +480,9 @@ func queuedReplyPresent(details PRDetails, operation mutationOperation) bool {
} }
matches := 0 matches := 0
for _, comment := range thread.Comments { for _, comment := range thread.Comments {
if operation.ReplyID != "" && comment.ID == operation.ReplyID {
return true
}
if comment.Body == operation.Body && strings.EqualFold(comment.Author, operation.Viewer) && if comment.Body == operation.Body && strings.EqualFold(comment.Author, operation.Viewer) &&
!comment.CreatedAt.Before(operation.EnqueuedAt.Add(-time.Minute)) { !comment.CreatedAt.Before(operation.EnqueuedAt.Add(-time.Minute)) {
matches++ matches++
@@ -546,6 +552,10 @@ func (m App) projectQueuedMutations(details PRDetails) PRDetails {
pendingID := "pending:" + operation.ID pendingID := "pending:" + operation.ID
found := false found := false
for index := range thread.Comments { for index := range thread.Comments {
if queuedReplyMatchesComment(thread.Comments[index], operation) {
found = true
continue
}
if thread.Comments[index].ID == pendingID { if thread.Comments[index].ID == pendingID {
thread.Comments[index].Pending = mutationNeedsAttention(operation) thread.Comments[index].Pending = mutationNeedsAttention(operation)
found = true found = true
@@ -573,6 +583,15 @@ func (m App) projectQueuedMutations(details PRDetails) PRDetails {
return details return details
} }
func queuedReplyMatchesComment(comment ReviewComment, operation mutationOperation) bool {
if operation.ReplyID != "" && comment.ID == operation.ReplyID {
return true
}
return comment.Body == operation.Body &&
strings.EqualFold(comment.Author, operation.Viewer) &&
!comment.CreatedAt.Before(operation.EnqueuedAt.Add(-time.Minute))
}
func mutationNeedsAttention(operation mutationOperation) bool { func mutationNeedsAttention(operation mutationOperation) bool {
return operation.Blocked || operation.Unverified return operation.Blocked || operation.Unverified
} }

View File

@@ -303,6 +303,56 @@ func TestReplayReconcilesReplyBeforeAskingAboutAmbiguousDelivery(t *testing.T) {
} }
} }
func TestReplyProjectionUsesRemoteCommentWithoutTemporaryDuplicate(t *testing.T) {
store := loadMutationQueue("")
enqueued := time.Now().Add(-time.Second)
operation := mutationOperation{
ID: "reply", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1,
ThreadID: "thread", Body: "body", Viewer: "me", EnqueuedAt: enqueued,
}
if err := store.add(operation); err != nil {
t.Fatal(err)
}
m := NewApp(nil, "o", "r", false, 50, time.Minute)
m.mutations = store
details := PRDetails{
PullRequest: PullRequest{Owner: "o", Repository: "r", Number: 1},
Threads: []ReviewThread{{ID: "thread", Comments: []ReviewComment{
{ID: "remote", Author: "me", Body: "body", CreatedAt: enqueued.Add(time.Second)},
}}},
}
projected := m.projectQueuedMutations(details)
if len(projected.Threads[0].Comments) != 1 || projected.Threads[0].Comments[0].ID != "remote" {
t.Fatalf("single remote reply was duplicated: %#v", projected.Threads[0].Comments)
}
details.Threads[0].Comments = append(details.Threads[0].Comments, ReviewComment{
ID: "actual-duplicate", Author: "me", Body: "body", CreatedAt: enqueued.Add(2 * time.Second),
})
projected = m.projectQueuedMutations(details)
if len(projected.Threads[0].Comments) != 2 {
t.Fatalf("remote duplicates were not preserved exactly: %#v", projected.Threads[0].Comments)
}
}
func TestSuccessfulReplyCheckpointsRemoteCommentID(t *testing.T) {
store := loadMutationQueue("")
operation := mutationOperation{
ID: "reply", Kind: mutationReply, Owner: "o", Repository: "r", Number: 1,
ThreadID: "thread", Body: "body", Viewer: "me", EnqueuedAt: time.Now(),
}
if err := store.add(operation); err != nil {
t.Fatal(err)
}
details := PRDetails{Threads: []ReviewThread{{ID: "thread", ViewerCanReply: true}}}
result := executeQueuedMutation(context.Background(), &recordingService{}, store, operation, details)
stored, ok := store.front()
if result.state != mutationReplayVerifying || !ok || stored.ReplyID != "new-comment" {
t.Fatalf("reply verification identity was not checkpointed: result=%#v stored=%#v", result, stored)
}
}
func TestSuccessfulResolutionWaitsForLiveVerification(t *testing.T) { func TestSuccessfulResolutionWaitsForLiveVerification(t *testing.T) {
store := loadMutationQueue("") store := loadMutationQueue("")
operation := mutationOperation{ operation := mutationOperation{

30
tui.go
View File

@@ -1638,6 +1638,8 @@ func (m *App) trackThreadUpdates(details PRDetails) {
state := m.readState.Data[prID] state := m.readState.Data[prID]
if state.Threads == nil { if state.Threads == nil {
state.Threads = make(map[string]bool) state.Threads = make(map[string]bool)
}
if state.Comments == nil {
state.Comments = make(map[string]bool) state.Comments = make(map[string]bool)
} }
if !state.Initialized { if !state.Initialized {
@@ -1654,25 +1656,47 @@ func (m *App) trackThreadUpdates(details PRDetails) {
} }
return return
} }
readStateChanged := false
for _, thread := range details.Threads { for _, thread := range details.Threads {
threadIsNew := !state.Threads[thread.ID] threadIsNew := !state.Threads[thread.ID]
updated := threadIsNew updated := threadIsNew
if threadIsNew { viewerAuthoredOnly := len(thread.Comments) > 0
m.newThreads[thread.ID] = true
}
for _, comment := range thread.Comments { for _, comment := range thread.Comments {
viewerAuthored := details.ViewerLogin != "" &&
strings.EqualFold(comment.Author, details.ViewerLogin)
if viewerAuthored {
if !state.Comments[comment.ID] {
state.Comments[comment.ID] = true
readStateChanged = true
}
} else {
viewerAuthoredOnly = false
}
if !state.Comments[comment.ID] { if !state.Comments[comment.ID] {
updated = true updated = true
m.unreadComments[comment.ID] = true m.unreadComments[comment.ID] = true
} }
m.knownComments[comment.ID] = true m.knownComments[comment.ID] = true
} }
if threadIsNew && !viewerAuthoredOnly {
m.newThreads[thread.ID] = true
} else if threadIsNew {
state.Threads[thread.ID] = true
readStateChanged = true
updated = false
}
m.knownThreads[thread.ID] = true m.knownThreads[thread.ID] = true
if updated { if updated {
m.unreadThreads[thread.ID] = true m.unreadThreads[thread.ID] = true
m.updatedThreads[thread.ID] = true m.updatedThreads[thread.ID] = true
} }
} }
if readStateChanged {
m.readState.Data[prID] = state
if err := m.readState.save(); err != nil {
m.recordHealth("read state", healthWarning, err.Error())
}
}
m.initializedPRs[prID] = true m.initializedPRs[prID] = true
} }

View File

@@ -1752,6 +1752,39 @@ func TestPollingMarksNewThreadCommentsUnread(t *testing.T) {
} }
} }
func TestPollingDoesNotMarkViewerReplyUnread(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
initial := PRDetails{
PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1},
ViewerLogin: "me",
Threads: []ReviewThread{{
ID: "thread", Comments: []ReviewComment{{ID: "original", Author: "reviewer"}},
}},
}
updated, _ := m.Update(detailsLoadedMsg{owner: "o", repo: "r", number: 1, details: initial})
m = updated.(App)
refreshed := initial
refreshed.Threads = []ReviewThread{{
ID: "thread", Comments: []ReviewComment{
{ID: "original", Author: "reviewer"},
{ID: "own-reply", Author: "ME", Body: "sent by me"},
},
}}
updated, _ = m.Update(detailsLoadedMsg{owner: "o", repo: "r", number: 1, details: refreshed})
m = updated.(App)
if m.unreadThreads["thread"] || m.unreadComments["own-reply"] || len(m.updatedThreads) != 0 {
t.Fatalf("viewer reply was marked new: threads=%v comments=%v updated=%v",
m.unreadThreads, m.unreadComments, m.updatedThreads)
}
state := m.readState.Data["pr"]
if !state.Comments["own-reply"] {
t.Fatal("viewer reply was not persisted as read")
}
}
func TestResolvingThreadMarksItRead(t *testing.T) { func TestResolvingThreadMarksItRead(t *testing.T) {
service := &recordingService{} service := &recordingService{}
m := NewApp(service, "o", "r", false, 50, time.Second) m := NewApp(service, "o", "r", false, 50, time.Second)