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

213
tui.go
View File

@@ -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"