Files
forgebot/internal/tui/app.go
T
unkin-agent 8c796f4087
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Adopt golib/pg for migrations and pool construction
forgebot applied its schema by executing an inline DDL string on every boot,
with no version tracking and no lock, so two API replicas starting together
raced and the SQL had nowhere to grow. golib/pg already owns that mechanic for
the estate; take it and keep owning the SQL.

- Move the schema into migrations/0001_init.sql, embedded via migrations.FS.
  The DDL is verbatim.
- The four legacy-status UPDATEs move into 0001 unchanged. Each reads only
  retired statuses (pending/failed/running/succeeded/cancelled) and writes only
  current ones, and no current status is a source, so replaying 0001 once
  against the live database is a no-op. A test pins that property.
- Build the pool with pg.NewMigrated, LockName "forgebot-migrations": the
  advisory lock serializes replicas, schema_migrations records what ran, and a
  migration failure fails startup instead of half-migrating. database.New and
  apiserver.New take a context and logger for it.
- Render the DSN with pg.DSN, which percent-escapes the credentials the
  fmt.Sprintf builder pasted in raw. LoadConfig still reads the environment
  itself: pg.DSNFromEnv has no defaults for user and database name, where
  forgebot defaults both to "forgebot", and would newly honour DATABASE_URL and
  PG*. The deployed DBHOST/DBPORT/DBUSER/DBPASS/DBNAME/DBSSL contract and its
  defaults are unchanged, and pinned by a test.
- Plumb GOPRIVATE=git.unkin.net for the first cross-repo Go dependency:
  exported by the Makefile, set in both Dockerfiles and the woodpecker Go
  steps, documented in the README.
- gofmt the four files that were already unformatted on main, so the
  pre-commit step can pass.
2026-09-02 00:16:58 +10:00

270 lines
5.5 KiB
Go

package tui
import (
"context"
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.unkin.net/unkin/forgebot/pkg/models"
)
type viewMode int
const (
viewBoard viewMode = iota
viewDetail
viewFilter
)
type tasksLoadedMsg struct {
tasks []models.Task
err error
}
type taskUpdatedMsg struct {
err error
}
type tickMsg time.Time
type App struct {
client *Client
board board
detail detailView
mode viewMode
width int
height int
err error
help help.Model
showHelp bool
filter textinput.Model
filterRepo string
}
func NewApp(apiURL string) App {
ti := textinput.New()
ti.Placeholder = "owner/repo"
ti.CharLimit = 100
return App{
client: NewClient(apiURL),
board: newBoard(),
detail: newDetailView(),
help: help.New(),
filter: ti,
}
}
func (a App) Init() tea.Cmd {
return tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd())
}
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.width = msg.Width
a.height = msg.Height
a.help.Width = msg.Width
return a, nil
case tasksLoadedMsg:
if msg.err != nil {
a.err = msg.err
} else {
a.err = nil
a.board.loadTasks(msg.tasks)
}
return a, nil
case taskUpdatedMsg:
if msg.err != nil {
a.err = msg.err
}
return a, fetchTasks(a.client, a.filterRepo)
case editorFinishedMsg:
if msg.err != nil {
a.err = msg.err
}
return a, fetchTasks(a.client, a.filterRepo)
case tickMsg:
return a, tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd())
case tea.KeyMsg:
if a.mode == viewFilter {
return a.updateFilter(msg)
}
if a.mode == viewDetail {
return a.updateDetail(msg)
}
return a.updateBoard(msg)
}
return a, nil
}
func (a App) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keys.Quit):
return a, tea.Quit
case key.Matches(msg, keys.Left):
a.board.moveLeft()
case key.Matches(msg, keys.Right):
a.board.moveRight()
case key.Matches(msg, keys.Up):
a.board.moveUp()
case key.Matches(msg, keys.Down):
a.board.moveDown()
case key.Matches(msg, keys.Enter):
if t := a.board.selectedTask(); t != nil {
a.mode = viewDetail
a.detail.setTask(t, a.width, a.height)
}
case key.Matches(msg, keys.Edit):
if t := a.board.selectedTask(); t != nil {
return a, editTaskCmd(t, a.client)
}
case key.Matches(msg, keys.New):
return a, newTaskEditorCmd(a.client)
case key.Matches(msg, keys.Done):
if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview {
return a, updateTaskStatus(a.client, t.ID, models.StatusDone)
}
case key.Matches(msg, keys.Wontdo):
if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview {
return a, updateTaskStatus(a.client, t.ID, models.StatusWontdo)
}
case key.Matches(msg, keys.Refresh):
return a, fetchTasks(a.client, a.filterRepo)
case key.Matches(msg, keys.Filter):
a.mode = viewFilter
a.filter.SetValue(a.filterRepo)
a.filter.Focus()
return a, nil
case key.Matches(msg, keys.Help):
a.showHelp = !a.showHelp
}
return a, nil
}
func (a App) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keys.Back), key.Matches(msg, keys.Quit):
a.mode = viewBoard
return a, nil
case key.Matches(msg, keys.Edit):
if a.detail.task != nil {
return a, editTaskCmd(a.detail.task, a.client)
}
}
var cmd tea.Cmd
a.detail.viewport, cmd = a.detail.viewport.Update(msg)
return a, cmd
}
func (a App) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
a.filterRepo = a.filter.Value()
a.mode = viewBoard
a.filter.Blur()
return a, fetchTasks(a.client, a.filterRepo)
case "esc":
a.mode = viewBoard
a.filter.Blur()
return a, nil
}
var cmd tea.Cmd
a.filter, cmd = a.filter.Update(msg)
return a, cmd
}
func (a App) View() string {
if a.width == 0 {
return "Loading..."
}
var content string
switch a.mode {
case viewDetail:
content = a.detail.view()
case viewFilter:
content = a.board.view(a.width, a.height-4)
content += "\n" + lipgloss.NewStyle().Bold(true).Render("Filter repo: ") + a.filter.View()
default:
content = a.board.view(a.width, a.height-3)
}
var statusLine string
if a.err != nil {
errText := a.err.Error()
if len(errText) > a.width-2 {
errText = errText[:a.width-5] + "..."
}
statusLine = errStyle.Render(errText)
} else if a.filterRepo != "" {
statusLine = dimStyle.Render(fmt.Sprintf("filter: %s", a.filterRepo))
}
var helpView string
if a.showHelp && a.mode == viewBoard {
helpView = a.help.View(keys)
} else if a.mode == viewBoard {
helpView = helpStyle.Render("? help q quit")
}
parts := []string{content}
if statusLine != "" {
parts = append(parts, statusLine)
}
if helpView != "" {
parts = append(parts, helpView)
}
return strings.Join(parts, "\n")
}
func fetchTasks(client *Client, repo string) tea.Cmd {
return func() tea.Msg {
tasks, err := client.ListTasks(context.Background(), "", repo)
return tasksLoadedMsg{tasks: tasks, err: err}
}
}
func updateTaskStatus(client *Client, id string, status models.TaskStatus) tea.Cmd {
return func() tea.Msg {
err := client.UpdateTask(context.Background(), id, models.UpdateTaskRequest{Status: status})
return taskUpdatedMsg{err: err}
}
}
func tickCmd() tea.Cmd {
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}