Make dashboard updateable

This commit is contained in:
2026-07-28 12:44:31 +02:00
parent 72e1eb1fda
commit bb8e91039f
21 changed files with 3482 additions and 43 deletions

View File

@@ -2,6 +2,9 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"slices"
"strings"
"testing"
@@ -21,6 +24,36 @@ type recordingService struct {
writeResolved bool
}
type recordingPRService struct {
recordingService
updateID string
update PullRequestMetadata
updateErr error
branches []RepositoryBranch
branchErr error
}
func (s *recordingPRService) UpdatePullRequest(
_ context.Context,
id string,
update PullRequestMetadata,
) (PullRequestMetadata, error) {
s.updateID, s.update = id, update
if s.updateErr != nil {
return PullRequestMetadata{}, s.updateErr
}
update.Mergeable = "UNKNOWN"
update.MergeState = "UNKNOWN"
update.UpdatedAt = time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
return update, nil
}
func (s *recordingPRService) ListBranches(
_ context.Context, _, _ string,
) ([]RepositoryBranch, error) {
return append([]RepositoryBranch(nil), s.branches...), s.branchErr
}
func (s *recordingService) SetThreadResolved(
_ context.Context, threadID string, resolved bool,
) (ReviewThread, error) {
@@ -513,6 +546,334 @@ func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) {
if !live[0].enabled || live[1].enabled || live[2].enabled {
t.Fatalf("live capabilities = %#v", live)
}
updatable := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanUpdatePR: true}}, thread)
if !updatable[3].enabled || updatable[3].name != "update pull request" {
t.Fatalf("pull request update capability = %#v", updatable[3])
}
}
func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) {
service := &recordingPRService{}
m := NewApp(service, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
m.details = PRDetails{
PullRequest: PullRequest{
ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r",
Number: 1, Title: "Old title",
},
Body: "- [ ] first\n- [ ] second", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
send := func(key tea.KeyMsg) tea.Cmd {
updated, command := m.Update(key)
m = updated.(App)
return command
}
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
if m.writeMode != writePREdit || m.prEditField != prEditBodyField {
t.Fatalf("edit key produced mode=%d field=%d", m.writeMode, m.prEditField)
}
editor := ansi.Strip(strings.Join(m.dashboardLines(), "\n"))
if !strings.Contains(editor, "EDITING") || !strings.Contains(editor, "- [ ] first") {
t.Fatalf("dashboard editor does not show the raw description:\n%s", editor)
}
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")})
send(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")})
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(";")})
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("i")})
send(tea.KeyMsg{Type: tea.KeyDelete})
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
send(tea.KeyMsg{Type: tea.KeyEsc})
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyHome})
for range len("main") {
send(tea.KeyMsg{Type: tea.KeyDelete})
}
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")})
send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyHome})
for range len("Old title") {
send(tea.KeyMsg{Type: tea.KeyDelete})
}
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("New title")})
send(tea.KeyMsg{Type: tea.KeyCtrlS})
if m.writeMode != writePREditConfirm {
t.Fatalf("ctrl-s produced mode=%d, error=%v", m.writeMode, m.err)
}
command := send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
if command == nil || m.writeMode != writePREditBusy {
t.Fatalf("confirmation produced mode=%d command=%v", m.writeMode, command)
}
updated, refresh := m.Update(command())
m = updated.(App)
if refresh == nil || service.updateID != "pr" {
t.Fatalf("update did not submit and refresh: id=%q refresh=%v", service.updateID, refresh)
}
if service.update.Title != "New title" || service.update.BaseRef != "release" ||
service.update.Body != "- [x] first\n- [ ] second" {
t.Fatalf("submitted metadata = %#v", service.update)
}
if m.writeMode != writeNone || m.details.Title != "New title" ||
m.details.BaseRef != "release" || m.details.Body != service.update.Body {
t.Fatalf("local metadata was not updated: mode=%d details=%#v", m.writeMode, m.details)
}
}
func TestDashboardEditorPreservesDraftAfterMutationFailure(t *testing.T) {
service := &recordingPRService{updateErr: errors.New("base branch does not exist")}
m := NewApp(service, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
m.details = PRDetails{
PullRequest: PullRequest{
ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r",
Number: 1, Title: "Title",
},
Body: "description", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.prEditEditors[prEditBodyField].Text = "changed description"
m.prEditEditors[prEditBodyField].Cursor = len([]rune("changed description"))
m.writeMode = writePREditBusy
message := m.submitPREdit()()
updated, _ := m.Update(message)
m = updated.(App)
if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Text != "changed description" ||
m.err == nil || !strings.Contains(m.err.Error(), "base branch does not exist") {
t.Fatalf("failed mutation lost editor state: mode=%d body=%q err=%v",
m.writeMode, m.prEditEditors[prEditBodyField].Text, m.err)
}
}
func TestDashboardEditorRejectsStaleMetadata(t *testing.T) {
service := &recordingPRService{}
m := NewApp(service, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "original", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.prEditEditors[prEditBodyField].Text = "my edit"
m.details.Body = "remote edit"
updated, command := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlS})
m = updated.(App)
if command != nil || m.writeMode != writePREdit || m.err == nil ||
!strings.Contains(m.err.Error(), "changed while editing") {
t.Fatalf("stale metadata was not blocked: mode=%d command=%v err=%v", m.writeMode, command, m.err)
}
}
func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) {
settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(&recordingPRService{}, "o", "r", false, 50, time.Second, settings)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "body", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
editor := m.prEditEditors[prEditBodyField]
if editor.Modal || editor.Mode != textEditorInsert || editor.Cursor != len([]rune("body")) {
t.Fatalf("standard description editor = %#v", editor)
}
}
func TestDashboardVimEscapeReturnsToNormalBeforeClosingEditor(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "body", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
updated, _ := m.updatePREditInput(runeKey("i"))
m = updated.(App)
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Mode != textEditorNormal {
t.Fatalf("insert escape closed editor: write=%d mode=%s", m.writeMode, m.prEditEditors[prEditBodyField].Mode)
}
updated, _ = m.updatePREditInput(runeKey("v"))
m = updated.(App)
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Mode != textEditorNormal {
t.Fatalf("visual escape closed editor: write=%d mode=%s", m.writeMode, m.prEditEditors[prEditBodyField].Mode)
}
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.writeMode != writeNone {
t.Fatalf("normal escape did not close editor: write=%d", m.writeMode)
}
}
func TestDashboardPositionsHardwareCursorAtInsertBoundary(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
if err != nil {
t.Fatal(err)
}
defer file.Close()
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.cursorOutput = newTerminalCursorOutput(file)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 40, 20
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "abcdefghij", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.prEditEditors[prEditBodyField].Cursor = 4
m.prEditEditors[prEditBodyField].Mode = textEditorInsert
m.positionPREditHardwareCursor(0, m.dashboardViewportHeight())
m.cursorOutput.mu.Lock()
visible, column, row := m.cursorOutput.visible, m.cursorOutput.column, m.cursorOutput.row
m.cursorOutput.mu.Unlock()
if !visible || column != 7 || row <= 0 {
t.Fatalf("hardware cursor visible=%v column=%d row=%d", visible, column, row)
}
}
func TestDashboardReturningToTitleRestoresTopAndFieldLabel(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 50, 12
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: strings.Repeat("description line\n", 30), BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.scroll = 30
updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(App)
if m.prEditField != prEditTitleField || m.scroll != 0 {
t.Fatalf("title navigation field=%d scroll=%d", m.prEditField, m.scroll)
}
view := ansi.Strip(m.viewDashboard())
if !strings.Contains(view, "Edit pull request") || !strings.Contains(view, "title") {
t.Fatalf("title context is not visible:\n%s", view)
}
}
func TestDashboardEditorHalfPageMotionsMoveCursorAndViewport(t *testing.T) {
var body strings.Builder
for index := range 30 {
fmt.Fprintf(&body, "line %02d\n", index)
}
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 50, 12
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: body.String(), BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
startCursor := m.prEditEditors[prEditBodyField].Cursor
startScroll := m.scroll
updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(App)
if m.prEditEditors[prEditBodyField].Cursor <= startCursor || m.scroll <= startScroll {
t.Fatalf("ctrl-d cursor=%d scroll=%d", m.prEditEditors[prEditBodyField].Cursor, m.scroll)
}
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlU})
m = updated.(App)
if m.prEditEditors[prEditBodyField].Cursor != startCursor || m.scroll != startScroll {
t.Fatalf(
"ctrl-u cursor=%d want=%d scroll=%d want=%d",
m.prEditEditors[prEditBodyField].Cursor, startCursor, m.scroll, startScroll,
)
}
}
func TestDashboardEditorHalfPageMotionExtendsVisualSelection(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 30, 12
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: strings.Repeat("abcdefghij", 20), BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
updated, _ := m.updatePREditInput(runeKey("v"))
m = updated.(App)
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlD})
m = updated.(App)
editor := m.prEditEditors[prEditBodyField]
start, end, selected := editor.selectionBounds(m.prEditEditorWidth())
if editor.Mode != textEditorVisual || !selected || end-start <= 1 {
t.Fatalf("visual ctrl-d mode=%s selection=%d:%d selected=%v", editor.Mode, start, end, selected)
}
}
func TestDashboardHardwareCursorMovementChangesZeroWidthFrameMarker(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
if err != nil {
t.Fatal(err)
}
defer file.Close()
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.cursorOutput = newTerminalCursorOutput(file)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 40, 20
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "abcdef", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
m.prEditEditors[prEditBodyField].Mode = textEditorInsert
first := m.viewDashboard()
m.prEditEditors[prEditBodyField].Cursor++
second := m.viewDashboard()
if first == second {
t.Fatal("hardware-cursor-only movement produced an identical frame")
}
if ansi.Strip(first) != ansi.Strip(second) {
t.Fatal("hardware cursor marker changed visible frame content")
}
}
func TestDashboardEditorNormalizesMixedLineEndingsWithoutCreatingAnEdit(t *testing.T) {
const remoteBody = "first\nsecond\r\nthird\r"
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: remoteBody, BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
if got := m.prEditEditors[prEditBodyField].Text; got != "first\nsecond\nthird\n" {
t.Fatalf("editor body = %q", got)
}
if got := m.prEditMetadata().Body; got != remoteBody {
t.Fatalf("unchanged payload body = %q, want original %q", got, remoteBody)
}
if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "unchanged") {
t.Fatalf("line-ending normalization counted as an edit: %v", err)
}
m.prEditEditors[prEditBodyField].Text += "changed"
if got := m.prEditMetadata().Body; strings.ContainsRune(got, '\r') {
t.Fatalf("edited payload retained carriage returns: %q", got)
}
}
func TestReplyComposerConfirmsAndAddsReturnedComment(t *testing.T) {