Compare commits
1 Commits
main
...
cc5b4eba1e
| Author | SHA1 | Date | |
|---|---|---|---|
| cc5b4eba1e |
25
README.md
25
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
|
||||
|
||||
|
||||
6
TODO.md
6
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
|
||||
|
||||
|
||||
@@ -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]")
|
||||
|
||||
2
main.go
2
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,
|
||||
|
||||
595
mutation_queue.go
Normal file
595
mutation_queue.go
Normal file
@@ -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()
|
||||
}
|
||||
214
mutation_queue_test.go
Normal file
214
mutation_queue_test.go
Normal file
@@ -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")
|
||||
}
|
||||
23
pr_editor.go
23
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{}
|
||||
|
||||
213
tui.go
213
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"
|
||||
|
||||
10
tui_test.go
10
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 {
|
||||
|
||||
3
types.go
3
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 (
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package main
|
||||
|
||||
const dipleVersion = "0.3.4"
|
||||
const dipleVersion = "0.4.0"
|
||||
|
||||
Reference in New Issue
Block a user