★ Renovate view: Renovate PRs, updates by dependency, bulk merge #10
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The ★ Renovate view is lazyforge's main differentiator: every open Renovate PR on the host in one place, the same update across repos grouped together, and merging an update everywhere in one step.
Plan
internal/core/renovate: detect Renovate PRs byrenovate_user(falling back to therenovate/branch prefix); parse the body with the approach from the Renovate parsing spike; group by package + from + to across repos; find each repo's Dependency Dashboard issue and parse its checkboxes.[1]Renovate PRs,[2]Updates by dependency,[3]Dashboards,[4]Failing / running CI.mon an update group merges every PR in the group through the same confirm dialog as bulk merge, with the CI warning.Plan-review additions (#17)
[1].[2]is question 5 on #13.[4]Failing / running CI ships in v1, are questions on #13.Context
docs/design.md→ ★ Renovate boxes and "Renovate features".docs/mockup.htmlhas the reference behavior.[2]is a pending product decision (see the product-decisions ticket).Out of scope
Ticking dashboard checkboxes (
[4]Renovate box in a regular repo): after v1.PM decisions (2026-10-02, #13)
[1]By repo (major/minor/patch counts and an impact score per repo, sorted worst first, host totals on top),[2]Updates by dependency,[3]Renovate PRs,[4]Dashboards,[5]Failing / running CI (Renovate PRs whose CI is failing or running, from commit status). See design.md → ★ Renovate.🤖 started: composer (planning)
🤖 #10 plan (composer Phase 1, approved by the engineering lead). Posted so another session can continue without re-planning. Branch
feature/10-renovate-view, fork_point6e08f15.Plan: #10 ★ Renovate view
Planned against
developat6e08f15(MR #32 merged). Planner notes:caveman:caveman,superpowers:brainstormingandsuperpowers:writing-plansskills are unavailable in this session. Structured questioning and plan format were done by hand.go-developmentwas loaded.Goal
The ★ Renovate row opens a five-box, host-wide view built from every repository the user can see:
[1]By repo,[2]Updates by dependency,[3]Renovate PRs,[4]Dashboards,[5]Failing / running CI. It fills in as repos load and shows coverage.mon an update group merges every PR in it through the existing #9 merge contract and confirm dialog.Task type
featureSize
large. It covers a new core package (parser, grouping, scoring, dashboard parsing), a domain field, an adapter mapping, core service integration, a new UI sub-view with five boxes, and a generalized multi-repo merge dialog. It is ~1,500 to 2,000 LOC across about 15 files and touches bulk merge, which carries money-like risk because merging is irreversible.Parallelism
sequential-only. S2 (UI) compiles against S1's types (renovate.RepoScan,renovate.View,Service.RenovateScan,domain.ChangeRequest.CreatedAt). Nothing else can run alongside S1 without manufacturing a docs-only worktree. The orchestrator should skip the worktree fan-out: S1, then S2, on one branch (feature/10-renovate-view).What already exists (verified in source)
forge.Forge.ListRepos(gitea:GET /user/repos) already returns owned, collaborator and team-reached org repos. That is the PM's scope ("repos you own or belong to, incl. orgs"). No new forge capability is needed.gitea.(*Forge).changeRequestalready fillsChangeRequest.CIfromGET …/commits/{sha}/statusfor every listed PR. Forgejo reports running Actions aspending(mapped todomain.CIPending), so "running" can't be told apart from pending on Forgejo. No new capability is needed.Forge.ListIssuesandcore.Service.Issuesexist. They are enough for Dependency Dashboards. No new capability is needed.domain.ChangeRequesthasUpdatedAtonly, and the giteapullstruct doesn't decodecreated_at(it is ininternal/forge/gitea/testdata/pulls.json). This must be added (S1).core.Service.Recheck(ctx, []Target) []Checkedandcore.Service.Merge(ctx, []Target) []MergeResult(internal/core/actions.go) already dedup by (repo, number), pin head SHA, classify per-target outcomes and apply the green-CI gate per repo. Reuse them unchanged.internal/ui/dialog.gomergeDialogassumes a single repo: it dedups by number only, uses onegreenOnlybool and one strategy, and shows no repo prefix. It also already listscr.Renovateupdates under each target. That list has been empty so far because nothing fillsRenovateyet. It must be generalized (S2).core.Coverage(internal/core/coverage.go) exists. It is single-goroutine, which fits UI ownership.internal/core/testdata/renovate/(README,prs.json, 9 PR bodies, 3 dashboards).SuggestRenovateUserlives ininternal/core/renovate.go(packagecore).ui.App.openSession(internal/ui/app.go:283) buildscore.Options.config.Host.RenovateUserexists. Demo mode buildscore.Options{}incmd/lazyforge/main.go. Demo Renovate PRs use authorrenovateand branchrenovate/…, and demo dashboards are titledDependency Dashboard, so the fallback rules below cover demo mode with no cmd change.Decisions (engineering lead; recorded in ADR 0013 by S1)
D1. Package
internal/core/renovateis pure. It importsdomainand the stdlib only, with no I/O, noforgeand nocore.coreimports it, anduimay import it for types and pure functions. This keeps the directionui → core(+core/renovate) → forge → domain. Fixtures stay atinternal/core/testdata/renovate/. Tests in the subpackage read../testdata/renovate/, which avoids moving spike files that the docs reference.D2. Detection. A change request is a Renovate PR when either of these holds:
user != ""andstrings.EqualFold(cr.Author, user)strings.HasPrefix(cr.SourceBranch, "renovate/")The rule is inclusive (OR), so a renamed bot account or a missing
renovate_usernever hides PRs. Therefs/pull/N/headgotcha only affects closed PRs, and the scan lists open PRs only, so no adapter change is needed for it.D3. Core fills
ChangeRequest.Renovatein its fetch path. This coversService.ChangeRequestsbefore caching, andService.Recheckon the fresh CR before it is written back and returned. It applies only to Renovate PRs (D2): the field becomes theParseresult, ornilwhen the body is rejected. Non-Renovate PRs keepnil. Because recheck re-parses, the confirm dialog always lists the updates a target carries at the pinned head SHA. Merging "postgres" never silently merges a grown group PR.core.OptionsgainsRenovateUser string.D4. Body parsing follows spike #2:
This PR contains the following updates:. Map cells by header name. Stop at the first non-table line.Package,UpdateandChange.Typeis optional.prBodyColumnsare accepted and ignored:Pending,References,File,Age,Adoption,Passing,Confidence. Merge-confidence badges are common on real instances, and rejecting them would drop most github.com bodies later.ok=false) on any of these: missing intro, no table, a row whose width differs from the header, a header outside that set, a missing required header, a Change cell that isn't exactly`from` → `to`(U+2192, both values backticked and non-empty), or zero rows.[name](url),[name](url) ([source](srcurl)), or plain/backtickedname.Packageis the name.SourceURLissrcurlwhen present, elseurl, else"".UpdateTypeis the Update cell lowercased.DepTypeis the Type cell or"".D5. Ecosystem inference. It never comes from the body alone:
actionanduses-withmap togithub-actions.finalandstagemap todocker.require,indirectandtoolchainmap togo.dependencies,devDependencies,peerDependenciesandoptionalDependenciesmap tonpm.docker tag,docker digestanddocker imagemap todocker.action(word) maps togithub-actions.modulemaps togo.helm releasemaps tohelm.""(unknown).D6. Grouping for box
[2]:GroupKey{Ecosystem, Package, To}, following the PM decision.package → to, and joins no group.Renovate == nil) is not in[2]at all. It still appears in[1],[3]and[5].Fromis not in the key. A group carries its distinct sortedFroms. The group'sUpdateTypeis the highest rank among its members (major > minor > patch > anything else).Label(ascending), then the first member's repo string.D7. Counting and impact for box
[1]:Major+Minor+Patch+Other == PRs.1 + log2(1 + days), wheredays = max(now.Sub(cr.CreatedAt), 0).Hours()/24as a fractional value. A zeroCreatedAtcounts as 0 days, which scores 1.D8. Box
[5]holds Renovate PRs whose!cr.CI.Green(): pending, running, fail and cancelled. On Forgejo, running shows as pending.D9. Dashboards.
Dependency Dashboard(EqualFold)user != "", the author matchesuser(EqualFold), and the body containsThis issue lists Renovate updatesParseDashboard(body)returns oneEntryper line matching^\s*- \[( |x|X)\]:Sectionis the text of the most recent##heading.Checkedis true forx/X.Branchis from the first<!-- <word>-branch=<value> -->, or"".Titleis the remaining text with HTML comments removed,[t](u)reduced tot, and surrounding space trimmed.## Detected Dependenciesheading.D10. Scan model.
Service.RenovateScan. That method fetches open CRs, then open issues, through the existing cached, semaphore-bounded methods. ADR 0007's 4-slot bound therefore limits the whole scan.core.Coverageand a per-repoRepoScanmap, and calls the purerenovate.Buildafter every result. This gives partial results while loading.cached=true(entering ★):PeekRenovateScanhits seed data and count as scanned, and only misses are fetched.cached=false(ror the five-minute tick while ★ is selected): Peek still seeds the display, but Coverage restarts empty and every repo is refetched.D11. Bulk merge from ★ reuses
Service.RecheckandService.Mergeunchanged:svc.Can(forge.ActMerge, repo)fails are excluded and listed in the dialog as skipped, with the reason.D12. No new dependencies. No change to
forge.Forgebeyond the domain field. No change to cmd.Subtasks
S1. Core Renovate logic, domain field, adapter mapping, demo data, ADR
Classification: independent. It runs first; S2 depends on it.
Scope
internal/domain/domain.go: addCreatedAt time.TimetoChangeRequest, afterUpdatedAt.internal/forge/gitea/forge.go:created_atonpull.ChangeRequest.CreatedAtinchangeRequest.internal/forge/forgetest/demo.go:CreatedAtthat is at or beforeUpdatedAt. Renovate PRs get varied open ages so impact scores differ, for example homelab#42 at 3 days, homelab#41 at 14 days, infra#18 at 40 days, infra#17 at 3 days, lazyforge#3 at 1 day.demo_test.goanddemo_ac_test.gopin them.internal/core/renovateholds D2, D4 to D9 andBuild. It may be split over several files.internal/core/service.goandinternal/core/actions.go:Options.RenovateUser.RenovateinChangeRequestsbefore caching, and inRecheckon the fresh CR before the cache update and return (D3).internal/core/renovate.go:Service.RenovateScanandService.PeekRenovateScan.SuggestRenovateUserunchanged.internal/core/testdata/renovate/: add hand-written fixtures from Renovate's documented format, and list them in a new "Hand-written" README section marked as not captured from a live instance:batched-gomod-nonmajor.md: three rows,Package | Type | Update | Change, Typerequire, minor/patch.digest-docker.md: one row, Updatedigest, backticked short SHAs.confidence-columns.md:Package | Change | Age | Adoption | Passing | ConfidenceplusUpdate, with badge image cells.rejected-unknown-column.md: an extraFoocolumn.rejected-no-arrow.md: Change cell without→.docs/adr/0013-renovate-view.md: a new ADR in the existing format (Status, Date 2026-10-02, Decided by: Opus, Context, Decision, Consequences, Alternatives) recording D1 to D12.docs/architecture.md:CreatedAtto the domain-model block.internal/core/renovateunder Layers or Caching.RenovateScanandCoveragesentence in "Caching and refresh".File domain (exclusive)
internal/domain/domain.gointernal/forge/gitea/forge.gointernal/forge/forgetest/demo.go,internal/forge/forgetest/demo_test.go,internal/forge/forgetest/demo_ac_test.go(adjust only if a body change requires it)internal/core/service.go,internal/core/actions.go,internal/core/renovate.gointernal/core/renovate/(new directory, all files)internal/core/testdata/renovate/(new fixtures plus README edit; existing captured fixtures unchanged)docs/adr/0013-renovate-view.md(new),docs/architecture.mdSeams (all exported; tests target these)
Buildordering:PRsandCIsort byCreatedAtascending (oldest first), then repo string, then number.Dashboardssort by repo string.ByRepoandGroupsfollow D7 and D6.Buildmust be deterministic for a given input regardless ofscansorder.Acceptance criteria
go build ./...passes, and existing tests pass (make check).IsRenovatefollows D2:renovate/branch is true.userrelies on the branch rule only.Parseon every captured PR fixture ininternal/core/testdata/renovate/(everyprs.jsonentry with a non-dashboard category) returnsok=truewith one update per table row.multi-manager-major_forgejo_9.mdgives twonodeupdates. The first has ecosystemgithub-actions, DepTypeuses-with,20→24. The second has ecosystemdocker, DepTypefinal,22-bookworm→24-bookworm.github-actions-major_home_2.mdgivesactions/checkout,github-actions,major,v4→v7.docker-compose-major_adventure_2.mdgives ecosystemdockerfrom the title nounDocker tag.docker-group-nonmajor_forgejo_13.mdgives packagecodeberg.org/forgejo/forgejo,SourceURLhttps://codeberg.org/forgejo/forgejo, patch16.0.3→16.0.5.Parsereturnsok=falseand nil for each of these:rejected-unknown-column.mdrejected-no-arrow.mdParseacceptsconfidence-columns.md,batched-gomod-nonmajor.md(3 updates, ecosystemgo) anddigest-docker.md.Ecosystem: a single-row body with no Type column and a title like
Update dependency node to v24yieldsEcosystem == "".ParseDashboard(dashboard_jellyfin_3.md)returns exactly one entry: SectionAwaiting Schedule, unchecked, Branchrenovate/docker-images-non-major, TitleUpdate jellyfin/jellyfin Docker tag to v12.1. Nothing comes from the Detected Dependencies section.IsDashboardis true for issues titledDependency Dashboardand false for an ordinary issue.Impactreturns 1 at 0 days, 2 at 1 day, 4 at 7 days, and 1 for a zero or futureopened.Buildfollows D6 to D8:actions/checkoutv3→v7 in repo A and v4→v7 in repo B (bothgithub-actions) form one group with 2 members andFroms ["v3","v4"].node→24 with ecosystemdockerandnode→24 with ecosystemgithub-actionsform two groups.Batchedrow with 1 member.Groupsbut present inPRsand counted inByRepounder Other.CIholds exactly the PRs with non-green CI.ByReposorts by impact, andTotalssums the rows.Service.ChangeRequestsreturns Renovate PRs withRenovatefilled (or nil when rejected) and non-Renovate PRs with nil.PeekChangeRequestsreturns the same filled values.Recheckreturns a re-parsedRenovatewhen the body changed on the forge.RenovateScanreturns only Renovate PRs and parsed dashboards for the repo. It wraps the error with the repo when either fetch fails, and it honors ctx cancellation.PeekRenovateScanreportsok=falseunless both the CR and issue lists are cached.The gitea adapter maps
created_attoCreatedAt(checked againsttestdata/pulls.json), and every demo CR has a non-zeroCreatedAt.internal/core/renovateimports onlydomainand stdlib packages (go list -depsshows nointernal/forge,internal/core,internal/uiorinternal/config).ADR 0013 exists and records D1 to D12. architecture.md is updated as in Scope item 9.
S2. ★ Renovate view in the UI and the generalized merge dialog
Classification:
sequential: depends on S1.Scope
Session wiring (
app.go):openSessionpassesRenovateUser: live.Load().Hosts[name].RenovateUserincore.Options.fromSession()markers for every new session message type.★ sub-model in a new file, for example
internal/ui/renovate.go. It holds:*core.Coveragemap[domain.RepoRef]renovate.RepoScanrenovate.View(recomputed inUpdate, never inView)It renders the five boxes with the existing
frame,row,splitHeightsandstylehelpers. Titles:[1] By repo[2] Updates by dependency[3] <crBoxTitle(term)>with the term-aware text. "Renovate PRs" is wrong on GitLab, so use"Renovate " + term + "s".[4] Dashboards[5] Failing / running CIRows, each truncated to width:
[1]:scanned X of Y repositories, with· N failedin the error style when failures exist.All · <PRs> · <M>M <m>m <p>p · <impact %.1f>.owner/name <M>M <m>m <p>p <impact>.[2]:Label, thenFromsfor non-batched rows. The meta shows×Nwhen N > 1, theUpdateType, and one CI icon per member.[3]and[5]: a mark glyph when marked, thenowner/name #n, thenpackage from → tofor a single update or the PR title otherwise. The meta shows the CI icon and age.[4]:owner/name ⚙ <issue title>, with the count of unchecked entries that have a branch, shown asN pending.Navigation:
lor1–5enters ★ boxes.j/k/gg/G/tab/shift-tab/1-5work as in regular boxes, andhgoes back.lon a row opens details. Details for a[1]row list that repo's Renovate PRs, which is howl"shows that repo's Renovate PRs".owner/name #n from → toplus CI).Couldn't read the update tablewhenRenovateis nil.host › ★ Renovate › [k] <title> › <item>.Loading (D10):
rand the five-minute tick while ★ is selected rescan withcached=false.context.Canceledis silent. A per-repo error goes toCoverage.Done(repo, err)and does not go to the status bar, because the[1]coverage line reports it.Actions in ★:
spacemarks or unmarks a PR row in[3]or[5]. Marks are shared across both boxes and keyed by (repo, number).escclears them.m:[2]it targets every member of the group under the cursor, ignoring marks.[3]or[5]it targets the marked PRs when any are marked, else the PR under the cursor.[1]or[4]it is disabled.Service.Recheckfirst. Then the dialog opens with:svc.Can(forge.ActMerge, repo), or not open any more!Coverage.Complete(), the warning<N> repositories not scanned; this may not be every PR for this update, where N is unscanned plus failedService.Merge. Results show per target. Unmerged targets become ★-marked, and merged ones unmark.PeekRenovateScanandViewis rebuilt.oopens the PR or dashboard web URL.a,xandcon PR rows, andxandcon dashboard rows, reuse the existing approve, close-dialog and comment commands with anItemRefbuilt from the row's repo. Key enablement followssvc.Can(…, rowRepo).loadBoxesorselectRepowith a zeroRepoRef.Dialog generalization (
dialog.go).mergeDialogtakes the targets plus an options value carrying:"" → "repo default", and a multi-repo target set showseach repo's defaultIt dedups by (repo, number). It prefixes each target with
owner/namewhen targets span more than one repo, or when opened from ★. It lists every update each target carries (existing loop), orpackages unknownfor a Renovate target with nilRenovate. The regular-repo merge path keeps its behavior and output. Update the existing caller inactions.go.Existing tests that assert the placeholder (
model_test.goaround lines 163 and 168,★ Renovate isn't built yet) are updated to the new behavior in this subtask. New behavior tests are Phase 8's job.File domain (exclusive)
internal/ui/(all files:app.go,model.go,actions.go,dialog.go,details.go,keys.go,repos.go,load.go,boxes.go,render.go, newrenovate*.go, and existing*_test.goonly where they encode the replaced placeholder)internal/ui/style/(only if a new style token is needed, for example a bump-label color)S2 must not touch
internal/core,internal/forge,internal/domain,cmd/ordocs/. A missing core seam is a fix on S1's files after S1 has merged, not a reshaped S1 API.Seams (package-internal; Phase 8 drives them through
Updatewith a realcore.Serviceoverforgetest.NewDemoorforgetest.Fake, and asserts state, commands and ANSI-strippedViewtext per ADR 0010)Observable behaviors (Phase 8 targets): the AC list below, driven by key messages (
tea.KeyPressMsg),tea.WindowSizeMsg{80, 24}, and the message types above.Acceptance criteria
make checkpasses, and every pre-existing UI test passes except the placeholder assertions this subtask updates.[1]…[5].[1]shows a totals line and one row per demo repo with Renovate PRs (homelab, infra, lazyforge), sorted by impact descending.[2]showspostgres 16.4 → 17.0(key docker+postgres+17.0) once, with×2.renovateScannedMsghave arrived, the boxes show the data received so far, and[1]showsscanned X of Y repositorieswith the current counts. A result with an oldseqchanges nothing. After a per-repo error,[1]shows· 1 failedand the status bar is unchanged.PeekRenovateScan) and fetches only misses.ron ★ starts acached=falsescan.mon a[2]group:Service.Recheckfor every member (deduped by repo and number), then opens a dialog listing each target asowner/name #n title, followed by every update the target carries. A batched PR lists all its packages.RequiresGreenCI.<N> repositories not scanned; this may not be every PR for this update.Service.Mergewith exactly the rechecked, open, write-accessible targets, pinned to the rechecked head SHAs.Mergeand appear in the dialog as skipped with theforge.Canreason. When no target remains, no dialog opens and the status bar explains why.[2],[3]and[5](rebuilt from Peek), and the status bar showsMerged k of n.spaceon a[3]or[5]PR toggles a mark that is visible in both boxes.mthere merges the marked set across repos, or the cursor PR when nothing is marked.escclears the marks.lon a[1]row lists that repo's Renovate PRs, andlon a[2]row lists ecosystem, update type, Froms and members. A PR with a rejected body showsCouldn't read the update table. A dashboard lists its entries by section with checked state.mis enabled only on[2],[3]and[5]rows with at least one mergeable target, andspaceonly on[3]and[5]. No string inuihardcodes "PR" or "MR" (box[3]usesHostInfo.ChangeRequestTerm).renovate_userto core (core.Options.RenovateUser). The new message types carry thefromSession()marker, so a replaced session's ★ results are dropped by the app's generation check.uiimports no adapter and calls noforge.Forgemethod. All I/O runs intea.Cmds, andUpdatenever blocks.Shared constraints
go-developmentskill:ui → core → forge → domain, withcore/renovateimporting domain and stdlib onlyUpdate…Msgmessages carrying keys and anerrfieldinternal/ui/stylebubbles/keybindings only, with no raw key strings in view code%wand context, and checked witherrors.Is/errors.Aspanic, nofmt.Printfrom UI code, and nohttp.DefaultClientcomment-standardskill. Exported identifiers get doc comments. Other comments explain why.//nolintwithout an engineering decision.tea.WindowSizeMsgonly.ChangeRequest. UI text derives "PR"/"MR" fromHostInfo.ChangeRequestTerm.Service.Recheck/Service.Merge. Never merge without a head SHA from a recheck done in thismcycle. Never let the dialog bypass the core green-CI gate.forgetest.type(scope): subject(feat,fix,chore,docs,refactor,test) with#10in the body. Examples:feat(core): Renovate parsing, grouping and host-wide scan,feat(ui): ★ Renovate view with update-group bulk merge,docs(adr): 0013 Renovate view. End each commit with the attribution line from the session.feature/10-renovate-viewofforigin/develop. The MR targetsdevelop.docs/design.mdis not edited.Verification (Phase 7, whole repo)
make check: format check,golangci-lint run,go test -race ./..., build.go list -deps ./internal/core/renovate | grep lazyforge/internal/shows onlyinternal/domain.tmux new-session -d -x 80 -y 24 'go run ./cmd/lazyforge --demo', thentmux capture-pane -p. Check that:[1]totalsl, then2, shows the postgres group with ×2mopens a dialog listinghome/homelab #42andhome/infra #17, each withpostgres 16.4 → 17.0and infra's running-CI warningesccancelsProduct questions (non-blocking; work proceeds on the recommended default)
[1]By repo count: PRs or updates?2M 1m 3p= 6 PRs and matches the impact score, which is per PR.renovate.Build's counting.🤖 Building now. One detail design.md leaves open, going in with this default. It's cheap to change:
By repo counts PRs, not updates. Each Renovate PR counts once, under its biggest bump, so a repo row like
2 major · 1 minor · 3 patchmeans 6 PRs and lines up with the impact score (also per PR). The alternative counts every package in a batched PR, so a five-package "non-major" PR would add 5 to minor. Say so if you'd rather have that.🤖 MR #33 open: reviews passed, tests backfilled. Merging once CI is green.
🤖 merged in #33