feat(dev): build remote pull requests locally

This commit is contained in:
Thomas Camlong committed 2026-08-01 23:35:08 +02:00
1 parent 934b6e69f4
commit c5a056b8e5
2 files changed
+81 -25

No files matched your search

+57 -25
View File
@@ -90,10 +90,11 @@ type prsModel struct {
}
type prsLoadedMsg struct {
prs []gh.PR
images []docker.Image
prErr error
imgErr error
prs []gh.PR
images []docker.Image
prErr error
imgErr error
refresh bool
}
type imageCheckedMsg struct {
number int
@@ -108,6 +109,7 @@ type rebuildDoneMsg struct {
image docker.Image
err error
canceled bool
action string
}
type runningRefreshedMsg struct {
rows []prRow
@@ -182,13 +184,19 @@ func prLogTick() tea.Cmd {
return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return prLogTickMsg{} })
}
func loadDev(includeBots bool) tea.Cmd {
func loadDev(includeBots, refresh bool) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
prs, prErr := gh.ListPRs(ctx, 50, includeBots)
var prs []gh.PR
var prErr error
if refresh {
prs, prErr = gh.RefreshPRs(ctx, 50, includeBots)
} else {
prs, prErr = gh.ListPRs(ctx, 50, includeBots)
}
images, imgErr := docker.ListLocalImages(ctx)
return prsLoadedMsg{prs: prs, images: images, prErr: prErr, imgErr: imgErr}
return prsLoadedMsg{prs: prs, images: images, prErr: prErr, imgErr: imgErr, refresh: refresh}
}
}
@@ -409,6 +417,14 @@ func (m prsModel) selectedPR() *prRow {
return row
}
func localBuildForRow(row prRow) (docker.Image, string, string) {
if row.kind == "remote" {
image := docker.Image{Tag: fmt.Sprintf("pr-%d", row.pr.Number), PRNumber: row.pr.Number}
return image, "built", fmt.Sprintf("building PR #%d locally as %s…", row.pr.Number, image.Reference())
}
return row.local, "rebuilt", "rebuilding " + row.local.Reference() + "…"
}
func (m *prsModel) refreshSelectedLogs() tea.Cmd {
row := m.selectedRow()
if row == nil || !row.running {
@@ -491,7 +507,7 @@ func (m prsModel) pullCompletion() (complete, total int, percent float64) {
}
func (m prsModel) Init() tea.Cmd {
return tea.Batch(loadDev(m.includeBots), m.spinner.Tick)
return tea.Batch(loadDev(m.includeBots, false), m.spinner.Tick)
}
func refreshRunning(rows []prRow, generation int) tea.Cmd {
@@ -540,13 +556,25 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else if msg.imgErr != nil {
m.status = "local images unavailable: " + msg.imgErr.Error()
}
imageStates := make(map[int]string, len(m.rows))
if !msg.refresh || msg.prErr != nil {
for _, row := range m.rows {
if row.kind == "remote" && (row.imageState == "yes" || row.imageState == "no") {
imageStates[row.pr.Number] = row.imageState
}
}
}
m.rows = make([]prRow, 0, len(msg.prs)+len(msg.images))
m.imageGen++
m.imageQueue = m.imageQueue[:0]
m.imageActive = 0
for _, pr := range msg.prs {
m.rows = append(m.rows, prRow{kind: "remote", pr: pr, imageState: "checking"})
m.imageQueue = append(m.imageQueue, pr.Number)
imageState := imageStates[pr.Number]
if imageState == "" {
imageState = "checking"
m.imageQueue = append(m.imageQueue, pr.Number)
}
m.rows = append(m.rows, prRow{kind: "remote", pr: pr, imageState: imageState})
}
for _, image := range msg.images {
m.rows = append(m.rows, prRow{kind: "local", local: image, imageState: "yes"})
@@ -589,14 +617,18 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.rebuildCancel = nil
m.loading = true
if msg.canceled {
m.status = "rebuild canceled"
} else if msg.err != nil {
m.status = "rebuild failed: " + msg.err.Error()
} else {
m.status = "rebuilt " + msg.image.Reference()
action := msg.action
if action == "" {
action = "rebuilt"
}
return m, tea.Batch(loadDev(m.includeBots), m.spinner.Tick)
if msg.canceled {
m.status = action + " canceled"
} else if msg.err != nil {
m.status = action + " failed: " + msg.err.Error()
} else {
m.status = action + " " + msg.image.Reference()
}
return m, tea.Batch(loadDev(m.includeBots, false), m.spinner.Tick)
case logsMsg:
if !m.showLogs || msg.name == "" || msg.name != m.logsName {
return m, nil
@@ -767,7 +799,7 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "r":
m.loading = true
m.status = "refreshing…"
return m, tea.Batch(loadDev(m.includeBots), m.spinner.Tick)
return m, tea.Batch(loadDev(m.includeBots, true), m.spinner.Tick)
case "/":
m.filtering = true
m.layout()
@@ -814,18 +846,18 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "R":
row := m.selectedRow()
if row == nil || row.kind != "local" {
m.status = "rebuild is only available for local images"
if row == nil {
m.status = "select an image to build"
return m, nil
}
image := row.local
image, action, status := localBuildForRow(*row)
ctx, cancel := context.WithCancel(context.Background())
m.rebuilding = true
m.rebuildCancel = cancel
m.status = "rebuilding " + image.Reference() + "…"
m.status = status
rebuild := func() tea.Msg {
err := run.RebuildImageContext(ctx, image, io.Discard, io.Discard)
return rebuildDoneMsg{image: image, err: err, canceled: ctx.Err() != nil}
return rebuildDoneMsg{image: image, err: err, canceled: ctx.Err() != nil, action: action}
}
return m, tea.Batch(rebuild, m.spinner.Tick)
case "m":
@@ -849,7 +881,7 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.status = "refreshing (bots hidden)…"
}
return m, tea.Batch(loadDev(m.includeBots), m.spinner.Tick)
return m, tea.Batch(loadDev(m.includeBots, false), m.spinner.Tick)
}
}
var cmd tea.Cmd
@@ -1003,7 +1035,7 @@ func (m prsModel) View() tea.View {
if m.rebuilding {
b.WriteString("\n\n" + helpStyle.Render("q/esc cancel rebuild"))
} else if !m.pulling {
b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · enter start/stop · R rebuild local · p pull remote · m demo · o PR · a app"))
b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · enter start/stop · R build locally · p pull remote · m demo · o PR · a app"))
b.WriteString("\n" + helpStyle.Render("b "+botState+" · r refresh · q quit"))
b.WriteString("\n" + helpStyle.Render("l toggle logs · f follow · pgup/pgdn scroll"))
}
+24
View File
@@ -59,6 +59,30 @@ func TestPRImageChecksAreBounded(t *testing.T) {
}
}
func TestPRLoadedReusesImageChecksUnlessRefreshIsForced(t *testing.T) {
m := newPRsModel()
m.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 1}, imageState: "yes"}}
updated, _ := m.Update(prsLoadedMsg{prs: []gh.PR{{Number: 1}, {Number: 2}}})
cached := updated.(prsModel)
if cached.rows[0].imageState != "yes" || len(cached.imageQueue) != 0 || cached.imageActive != 1 {
t.Fatalf("cached states were not reused: rows=%#v queue=%v active=%d", cached.rows, cached.imageQueue, cached.imageActive)
}
updated, _ = cached.Update(prsLoadedMsg{prs: []gh.PR{{Number: 1}, {Number: 2}}, refresh: true})
refreshed := updated.(prsModel)
if refreshed.rows[0].imageState != "checking" || refreshed.imageActive != 2 {
t.Fatalf("forced refresh reused image states: rows=%#v active=%d", refreshed.rows, refreshed.imageActive)
}
}
func TestRemotePRBuildUsesTemporaryPRWorkflow(t *testing.T) {
image, action, status := localBuildForRow(prRow{kind: "remote", pr: gh.PR{Number: 6441}})
if image.Tag != "pr-6441" || image.PRNumber != 6441 || action != "built" || !strings.Contains(status, "building PR #6441 locally") {
t.Fatalf("image=%#v action=%q status=%q", image, action, status)
}
}
func TestPRWithoutImageDoesNotLaunch(t *testing.T) {
m := newPRsModel()
m.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6441, Title: "No image"}, imageState: "no"}}