★ Renovate view: Renovate PRs, updates by dependency, bulk merge #10

Closed
opened 2026-10-01 21:55:16 -05:00 by deadstyle · 5 comments
Owner

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

  1. internal/core/renovate: detect Renovate PRs by renovate_user (falling back to the renovate/ 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.
  2. The ★ Renovate entry at the top of the repo list, with boxes [1] Renovate PRs, [2] Updates by dependency, [3] Dashboards, [4] Failing / running CI.
  3. m on an update group merges every PR in the group through the same confirm dialog as bulk merge, with the CI warning.
  4. Load across all repos with the concurrency limits from the core ticket. Show partial results while repos are still loading.

Plan-review additions (#17)

  • Grouping identity: key on ecosystem + package + from + to (from #2), not the display name. PRs whose body didn't parse cleanly never join a group; they still appear in [1].
  • Batched PRs: a merge target is a PR, not an update. Deduplicate targets, and the confirm dialog lists every update each target PR carries, so merging "postgres" never silently merges nine other packages. How batches appear in [2] is question 5 on #13.
  • Bulk merge uses #9's contract: head-SHA validation, per-target results, reconcile before retry.
  • Coverage: show "scanned X of Y repositories" while loading (from #7). If any repo failed or hasn't loaded, the merge confirmation says so ("2 repositories not scanned; this may not be every PR for this update").
  • Which repos count as "every repo on the host", and whether [4] Failing / running CI ships in v1, are questions on #13.

Context

  • docs/design.md → ★ Renovate boxes and "Renovate features". docs/mockup.html has the reference behavior.
  • Batched non-major PRs contain several packages. How they appear in [2] is a pending product decision (see the product-decisions ticket).
  • Depends on the Renovate parsing spike, the gitea adapter, the UI shell, and the actions ticket (for bulk merge).

Out of scope

Ticking dashboard checkboxes ([4] Renovate box in a regular repo): after v1.

PM decisions (2026-10-02, #13)

  • Boxes are now five: [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.
  • Impact score: per open Renovate PR, 1 + log₂(1 + days open), summed per repo. Majors aren't weighted extra.
  • Grouping key: ecosystem + package + target version (the starting version is not in the key; the row lists them). Batched PRs get their own row and always merge whole.
  • Scope: repos the user owns or belongs to (incl. orgs).
  • Bulk merge follows #9's contract (carry on past failures, per-PR results, failed ones stay marked).
## 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 1. `internal/core/renovate`: detect Renovate PRs by `renovate_user` (falling back to the `renovate/` 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. 2. The ★ Renovate entry at the top of the repo list, with boxes `[1]` Renovate PRs, `[2]` Updates by dependency, `[3]` Dashboards, `[4]` Failing / running CI. 3. `m` on an update group merges every PR in the group through the same confirm dialog as bulk merge, with the CI warning. 4. Load across all repos with the concurrency limits from the core ticket. Show partial results while repos are still loading. ### Plan-review additions (#17) - **Grouping identity:** key on ecosystem + package + from + to (from #2), not the display name. PRs whose body didn't parse cleanly never join a group; they still appear in `[1]`. - **Batched PRs:** a merge target is a PR, not an update. Deduplicate targets, and the confirm dialog lists every update each target PR carries, so merging "postgres" never silently merges nine other packages. How batches appear in `[2]` is question 5 on #13. - **Bulk merge uses #9's contract:** head-SHA validation, per-target results, reconcile before retry. - **Coverage:** show "scanned X of Y repositories" while loading (from #7). If any repo failed or hasn't loaded, the merge confirmation says so ("2 repositories not scanned; this may not be every PR for this update"). - Which repos count as "every repo on the host", and whether `[4]` Failing / running CI ships in v1, are questions on #13. ## Context - `docs/design.md` → ★ Renovate boxes and "Renovate features". `docs/mockup.html` has the reference behavior. - Batched non-major PRs contain several packages. How they appear in `[2]` is a pending product decision (see the product-decisions ticket). - Depends on the Renovate parsing spike, the gitea adapter, the UI shell, and the actions ticket (for bulk merge). ## Out of scope Ticking dashboard checkboxes (`[4]` Renovate box in a regular repo): after v1. ## PM decisions (2026-10-02, #13) - **Boxes are now five:** `[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. - **Impact score:** per open Renovate PR, 1 + log₂(1 + days open), summed per repo. Majors aren't weighted extra. - **Grouping key:** ecosystem + package + target version (the starting version is not in the key; the row lists them). Batched PRs get their own row and always merge whole. - **Scope:** repos the user owns or belongs to (incl. orgs). - **Bulk merge** follows #9's contract (carry on past failures, per-PR results, failed ones stay marked).
Author
Owner

🤖 started: composer (planning)

🤖 started: composer (planning)
Author
Owner

🤖 #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_point 6e08f15.

Plan: #10 ★ Renovate view

Planned against develop at 6e08f15 (MR #32 merged). Planner notes: caveman:caveman, superpowers:brainstorming and superpowers:writing-plans skills are unavailable in this session. Structured questioning and plan format were done by hand. go-development was 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. m on an update group merges every PR in it through the existing #9 merge contract and confirm dialog.

Task type

feature

Size

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)

  • Repo scope. 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.
  • CI per PR. gitea.(*Forge).changeRequest already fills ChangeRequest.CI from GET …/commits/{sha}/status for every listed PR. Forgejo reports running Actions as pending (mapped to domain.CIPending), so "running" can't be told apart from pending on Forgejo. No new capability is needed.
  • Issues. Forge.ListIssues and core.Service.Issues exist. They are enough for Dependency Dashboards. No new capability is needed.
  • PR created date. Missing. domain.ChangeRequest has UpdatedAt only, and the gitea pull struct doesn't decode created_at (it is in internal/forge/gitea/testdata/pulls.json). This must be added (S1).
  • Merge contract. core.Service.Recheck(ctx, []Target) []Checked and core.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.
  • Confirm dialog. internal/ui/dialog.go mergeDialog assumes a single repo: it dedups by number only, uses one greenOnly bool and one strategy, and shows no repo prefix. It also already lists cr.Renovate updates under each target. That list has been empty so far because nothing fills Renovate yet. It must be generalized (S2).
  • Coverage. core.Coverage (internal/core/coverage.go) exists. It is single-goroutine, which fits UI ownership.
  • Spike #2 fixtures are already on develop at internal/core/testdata/renovate/ (README, prs.json, 9 PR bodies, 3 dashboards).
  • SuggestRenovateUser lives in internal/core/renovate.go (package core).
  • Session wiring. ui.App.openSession (internal/ui/app.go:283) builds core.Options. config.Host.RenovateUser exists. Demo mode builds core.Options{} in cmd/lazyforge/main.go. Demo Renovate PRs use author renovate and branch renovate/…, and demo dashboards are titled Dependency 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/renovate is pure. It imports domain and the stdlib only, with no I/O, no forge and no core. core imports it, and ui may import it for types and pure functions. This keeps the direction ui → core(+core/renovate) → forge → domain. Fixtures stay at internal/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 != "" and strings.EqualFold(cr.Author, user)
    • strings.HasPrefix(cr.SourceBranch, "renovate/")

    The rule is inclusive (OR), so a renamed bot account or a missing renovate_user never hides PRs. The refs/pull/N/head gotcha only affects closed PRs, and the scan lists open PRs only, so no adapter change is needed for it.

  • D3. Core fills ChangeRequest.Renovate in its fetch path. This covers Service.ChangeRequests before caching, and Service.Recheck on the fresh CR before it is written back and returned. It applies only to Renovate PRs (D2): the field becomes the Parse result, or nil when the body is rejected. Non-Renovate PRs keep nil. 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.Options gains RenovateUser string.

  • D4. Body parsing follows spike #2:

    • Take the first Markdown table after the line This PR contains the following updates:. Map cells by header name. Stop at the first non-table line.
    • Required headers are Package, Update and Change. Type is optional.
    • Refinement: these documented Renovate prBodyColumns are 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.
    • Reject the whole body (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.
    • Package cell forms are [name](url), [name](url) ([source](srcurl)), or plain/backticked name. Package is the name. SourceURL is srcurl when present, else url, else "".
    • UpdateType is the Update cell lowercased. DepType is the Type cell or "".
  • D5. Ecosystem inference. It never comes from the body alone:

    • Per-row Type cell: action and uses-with map to github-actions. final and stage map to docker. require, indirect and toolchain map to go. dependencies, devDependencies, peerDependencies and optionalDependencies map to npm.
    • No Type cell and exactly one row: use the title noun, case-insensitive substring match. docker tag, docker digest and docker image map to docker. action (word) maps to github-actions. module maps to go. helm release maps to helm.
    • Anything else is "" (unknown).
  • D6. Grouping for box [2]:

    • A Renovate PR with exactly one parsed update and a known ecosystem joins the group keyed by GroupKey{Ecosystem, Package, To}, following the PM decision.
    • A PR with more than one update is batched. It gets its own row, labelled with its title, and always merges whole.
    • A PR with one update and an unknown ecosystem gets its own row, labelled package → to, and joins no group.
    • A PR whose body was rejected (Renovate == nil) is not in [2] at all. It still appears in [1], [3] and [5].
    • From is not in the key. A group carries its distinct sorted Froms. The group's UpdateType is the highest rank among its members (major > minor > patch > anything else).
    • Rows sort by member count (descending), then Label (ascending), then the first member's repo string.
  • D7. Counting and impact for box [1]:

    • Each Renovate PR counts once, in the bucket of its highest-ranked update type: Major, Minor, Patch, or Other. Other covers digest, pin, unknown and rejected bodies. So Major+Minor+Patch+Other == PRs.
    • Impact per PR is 1 + log2(1 + days), where days = max(now.Sub(cr.CreatedAt), 0).Hours()/24 as a fractional value. A zero CreatedAt counts as 0 days, which scores 1.
    • A repo's impact is the sum over its Renovate PRs. Majors aren't weighted extra.
    • Repos with no Renovate PRs are omitted. Rows sort by impact (descending), then repo string (ascending). Totals are the sums.
    • Count unit, see PQ1.
  • D8. Box [5] holds Renovate PRs whose !cr.CI.Green(): pending, running, fail and cancelled. On Forgejo, running shows as pending.

  • D9. Dashboards.

    • An issue is a dashboard when either of these holds:
      • its trimmed title equals Dependency Dashboard (EqualFold)
      • user != "", the author matches user (EqualFold), and the body contains This issue lists Renovate updates
    • ParseDashboard(body) returns one Entry per line matching ^\s*- \[( |x|X)\] :
      • Section is the text of the most recent ## heading.
      • Checked is true for x/X.
      • Branch is from the first <!-- <word>-branch=<value> -->, or "".
      • Title is the remaining text with HTML comments removed, [t](u) reduced to t, and surrounding space trimmed.
    • Parsing stops at a ## Detected Dependencies heading.
    • Ticking stays out of scope.
  • D10. Scan model.

    • The UI issues one command per repo, and each calls 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.
    • The UI owns a core.Coverage and a per-repo RepoScan map, and calls the pure renovate.Build after every result. This gives partial results while loading.
    • Each scan has a sequence number. Results from an older scan are dropped. The scan runs on its own context, which is cancelled when the cursor leaves the ★ row (navigating away cancels, per go-development).
    • cached=true (entering ★): PeekRenovateScan hits seed data and count as scanned, and only misses are fetched.
    • cached=false (r or 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.Recheck and Service.Merge unchanged:

    • Targets are deduped by (repo, number).
    • Targets in repos where svc.Can(forge.ActMerge, repo) fails are excluded and listed in the dialog as skipped, with the reason.
    • After the merge, every target that didn't merge stays or becomes ★-marked, and merged ones are unmarked (#9 contract).
    • The dialog is generalized, not forked.
  • D12. No new dependencies. No change to forge.Forge beyond 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

  1. internal/domain/domain.go: add CreatedAt time.Time to ChangeRequest, after UpdatedAt.
  2. internal/forge/gitea/forge.go:
    • Decode created_at on pull.
    • Map it to ChangeRequest.CreatedAt in changeRequest.
  3. internal/forge/forgetest/demo.go:
    • Give every demo CR a non-zero CreatedAt that is at or before UpdatedAt. 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.
    • Do not change repo, PR, issue or run counts or numbers. demo_test.go and demo_ac_test.go pin them.
    • Changing a demo PR body (for example making lazyforge#3 a two-row batched body) is allowed if those tests still pass, or are adjusted in this domain.
  4. New package internal/core/renovate holds D2, D4 to D9 and Build. It may be split over several files.
  5. internal/core/service.go and internal/core/actions.go:
    • Add Options.RenovateUser.
    • Fill Renovate in ChangeRequests before caching, and in Recheck on the fresh CR before the cache update and return (D3).
  6. internal/core/renovate.go:
    • Add Service.RenovateScan and Service.PeekRenovateScan.
    • Keep SuggestRenovateUser unchanged.
  7. 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, Type require, minor/patch.
    • digest-docker.md: one row, Update digest, backticked short SHAs.
    • confidence-columns.md: Package | Change | Age | Adoption | Passing | Confidence plus Update, with badge image cells.
    • rejected-unknown-column.md: an extra Foo column.
    • rejected-no-arrow.md: Change cell without →.
  8. 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.
  9. docs/architecture.md:
    • Add CreatedAt to the domain-model block.
    • Mention internal/core/renovate under Layers or Caching.
    • Add a RenovateScan and Coverage sentence in "Caching and refresh".
    • Link ADR 0013.
    • Leave design.md untouched.

File domain (exclusive)

  • internal/domain/domain.go
  • internal/forge/gitea/forge.go
  • internal/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.go
  • internal/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.md

Seams (all exported; tests target these)

// internal/domain
type ChangeRequest struct { /* … */ CreatedAt time.Time /* … */ }

// internal/core/renovate
func IsRenovate(cr domain.ChangeRequest, user string) bool
func Parse(title, body string) (updates []domain.RenovateUpdate, ok bool)
func IsDashboard(is domain.Issue, user string) bool
type Entry struct {
    Section, Title, Branch string
    Checked                bool
}
func ParseDashboard(body string) []Entry
func Impact(opened, now time.Time) float64

type Member struct {
    Repo domain.RepoRef
    CR   domain.ChangeRequest // CR.Renovate filled, nil when the body was rejected
}
type GroupKey struct{ Ecosystem, Package, To string }
type Group struct {
    Key        GroupKey  // zero value for batched and unknown-ecosystem rows
    Batched    bool      // the single member carries more than one update
    Label      string    // "package → to", or the PR title when Batched
    UpdateType string    // highest-ranked update type among members
    Froms      []string  // distinct, sorted; nil when Batched
    Members    []Member
}
type RepoSummary struct {
    Repo                             domain.RepoRef // zero for host totals
    PRs, Major, Minor, Patch, Other  int
    Impact                           float64
}
type Dashboard struct {
    Repo    domain.RepoRef
    Issue   domain.Issue
    Entries []Entry
}
type RepoScan struct {
    Repo       domain.Repo          // full repo, so the UI can check access and merge style
    PRs        []domain.ChangeRequest // Renovate PRs only (D2), Renovate field filled per D3
    Dashboards []Dashboard
}
type View struct {
    Totals     RepoSummary
    ByRepo     []RepoSummary // [1]
    Groups     []Group       // [2]
    PRs        []Member      // [3]
    Dashboards []Dashboard   // [4]
    CI         []Member      // [5]
}
func Build(scans []RepoScan, now time.Time) View

// internal/core
type Options struct { /* existing */ RenovateUser string }
func (s *Service) RenovateScan(ctx context.Context, r domain.Repo) (renovate.RepoScan, error)
func (s *Service) PeekRenovateScan(r domain.Repo) (renovate.RepoScan, bool)
// existing, behavior extended by D3:
func (s *Service) ChangeRequests(ctx context.Context, r domain.RepoRef) ([]domain.ChangeRequest, error)
func (s *Service) Recheck(ctx context.Context, ts []Target) []Checked

Build ordering:

  • PRs and CI sort by CreatedAt ascending (oldest first), then repo string, then number.
  • Dashboards sort by repo string.
  • ByRepo and Groups follow D7 and D6.
  • Build must be deterministic for a given input regardless of scans order.

Acceptance criteria

  1. go build ./... passes, and existing tests pass (make check).

  2. IsRenovate follows D2:

    • Matching author with any branch is true.
    • Any author with a renovate/ branch is true.
    • Author matching is case-insensitive.
    • An empty user relies on the branch rule only.
  3. Parse on every captured PR fixture in internal/core/testdata/renovate/ (every prs.json entry with a non-dashboard category) returns ok=true with one update per table row.

    • multi-manager-major_forgejo_9.md gives two node updates. The first has ecosystem github-actions, DepType uses-with, 20→24. The second has ecosystem docker, DepType final, 22-bookworm→24-bookworm.
    • github-actions-major_home_2.md gives actions/checkout, github-actions, major, v4→v7.
    • docker-compose-major_adventure_2.md gives ecosystem docker from the title noun Docker tag.
    • docker-group-nonmajor_forgejo_13.md gives package codeberg.org/forgejo/forgejo, SourceURL https://codeberg.org/forgejo/forgejo, patch 16.0.3→16.0.5.
  4. Parse returns ok=false and nil for each of these:

    • all three dashboard fixtures
    • rejected-unknown-column.md
    • rejected-no-arrow.md
    • a body without the intro line
    • a row-width mismatch
    • a table with zero rows

    Parse accepts confidence-columns.md, batched-gomod-nonmajor.md (3 updates, ecosystem go) and digest-docker.md.

  5. Ecosystem: a single-row body with no Type column and a title like Update dependency node to v24 yields Ecosystem == "".

  6. ParseDashboard(dashboard_jellyfin_3.md) returns exactly one entry: Section Awaiting Schedule, unchecked, Branch renovate/docker-images-non-major, Title Update jellyfin/jellyfin Docker tag to v12.1. Nothing comes from the Detected Dependencies section. IsDashboard is true for issues titled Dependency Dashboard and false for an ordinary issue.

  7. Impact returns 1 at 0 days, 2 at 1 day, 4 at 7 days, and 1 for a zero or future opened.

  8. Build follows D6 to D8:

    • actions/checkout v3→v7 in repo A and v4→v7 in repo B (both github-actions) form one group with 2 members and Froms ["v3","v4"].
    • node→24 with ecosystem docker and node→24 with ecosystem github-actions form two groups.
    • A batched PR forms its own Batched row with 1 member.
    • A rejected PR is absent from Groups but present in PRs and counted in ByRepo under Other.
    • CI holds exactly the PRs with non-green CI.
    • ByRepo sorts by impact, and Totals sums the rows.
  9. Service.ChangeRequests returns Renovate PRs with Renovate filled (or nil when rejected) and non-Renovate PRs with nil. PeekChangeRequests returns the same filled values. Recheck returns a re-parsed Renovate when the body changed on the forge.

  10. RenovateScan returns 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. PeekRenovateScan reports ok=false unless both the CR and issue lists are cached.

  11. The gitea adapter maps created_at to CreatedAt (checked against testdata/pulls.json), and every demo CR has a non-zero CreatedAt.

  12. internal/core/renovate imports only domain and stdlib packages (go list -deps shows no internal/forge, internal/core, internal/ui or internal/config).

  13. 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

  1. Session wiring (app.go):

    • openSession passes RenovateUser: live.Load().Hosts[name].RenovateUser in core.Options.
    • Add fromSession() markers for every new session message type.
  2. ★ sub-model in a new file, for example internal/ui/renovate.go. It holds:

    • the scan sequence, scan context and cancel
    • a *core.Coverage
    • a map[domain.RepoRef]renovate.RepoScan
    • the current renovate.View (recomputed in Update, never in View)
    • five cursors, the focus, and marks keyed by (repo, number)

    It renders the five boxes with the existing frame, row, splitHeights and style helpers. 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 CI
  3. Rows, each truncated to width:

    • [1]:
      • While coverage is incomplete, the first line is scanned X of Y repositories, with · N failed in the error style when failures exist.
      • Next is the host totals line: All · <PRs> · <M>M <m>m <p>p · <impact %.1f>.
      • Then one row per repo: owner/name <M>M <m>m <p>p <impact>.
    • [2]: Label, then Froms for non-batched rows. The meta shows ×N when N > 1, the UpdateType, and one CI icon per member.
    • [3] and [5]: a mark glyph when marked, then owner/name #n, then package from → to for 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 as N pending.
  4. Navigation:

    • With the repo-list cursor on ★, the right column previews the five boxes. This replaces the "isn't built yet" placeholder.
    • l or 1–5 enters ★ boxes. j/k/gg/G/tab/shift-tab/1-5 work as in regular boxes, and h goes back.
    • l on a row opens details. Details for a [1] row list that repo's Renovate PRs, which is how l "shows that repo's Renovate PRs".
    • Details for a group list the ecosystem, update type, Froms, and every member (owner/name #n from → to plus CI).
    • Details for a PR show the existing overview plus its updates, or Couldn't read the update table when Renovate is nil.
    • Details for a dashboard list entries by section with ☐/☑.
    • The breadcrumb reads host › ★ Renovate › [k] <title> › <item>.
  5. Loading (D10):

    • The scan starts when the repo list has loaded and ★ is selected, which includes startup, since the cursor starts on ★. It also starts when the cursor returns to ★.
    • Moving the repo-list cursor off ★ cancels the scan context.
    • r and the five-minute tick while ★ is selected rescan with cached=false.
    • Results with a stale sequence are dropped. context.Canceled is silent. A per-repo error goes to Coverage.Done(repo, err) and does not go to the status bar, because the [1] coverage line reports it.
  6. Actions in ★:

    • space marks or unmarks a PR row in [3] or [5]. Marks are shared across both boxes and keyed by (repo, number). esc clears them.
    • m:
      • On [2] it targets every member of the group under the cursor, ignoring marks.
      • On [3] or [5] it targets the marked PRs when any are marked, else the PR under the cursor.
      • On [1] or [4] it is disabled.
    • Targets go through Service.Recheck first. Then the dialog opens with:
      • the still-open targets
      • skipped targets: no write access per svc.Can(forge.ActMerge, repo), or not open any more
      • for group merges only, when !Coverage.Complete(), the warning <N> repositories not scanned; this may not be every PR for this update, where N is unscanned plus failed
    • Confirm runs Service.Merge. Results show per target. Unmerged targets become ★-marked, and merged ones unmark.
    • After a recheck, a merge, or another action, affected repos reseed from PeekRenovateScan and View is rebuilt.
    • o opens the PR or dashboard web URL. a, x and c on PR rows, and x and c on dashboard rows, reuse the existing approve, close-dialog and comment commands with an ItemRef built from the row's repo. Key enablement follows svc.Can(…, rowRepo).
    • The ★ path never calls loadBoxes or selectRepo with a zero RepoRef.
  7. Dialog generalization (dialog.go). mergeDialog takes the targets plus an options value carrying:

    • a strategy string: "" → "repo default", and a multi-repo target set shows each repo's default
    • a per-repo green-only predicate, replacing the single bool
    • a coverage warning string
    • skipped lines
    • a flag that routes confirm to the ★ merge

    It dedups by (repo, number). It prefixes each target with owner/name when targets span more than one repo, or when opened from ★. It lists every update each target carries (existing loop), or packages unknown for a Renovate target with nil Renovate. The regular-repo merge path keeps its behavior and output. Update the existing caller in actions.go.

  8. Existing tests that assert the placeholder (model_test.go around 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, new renovate*.go, and existing *_test.go only 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/ or docs/. 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 Update with a real core.Service over forgetest.NewDemo or forgetest.Fake, and asserts state, commands and ANSI-stripped View text per ADR 0010)

// messages (each has a fromSession() marker in app.go)
type renovateScannedMsg struct {
    seq  int
    repo domain.RepoRef
    scan renovate.RepoScan
    err  error
}
type starRecheckedMsg struct {
    results []core.Checked
    group   bool // the targets came from a [2] group, so the coverage warning applies
}
type starMergeDoneMsg struct{ results []core.MergeResult }

// ★ boxes, in order
const (
    starByRepo starBox = iota // [1]
    starGroups                // [2]
    starPRs                   // [3]
    starDashboards            // [4]
    starCI                    // [5]
)
type starTarget struct {
    Repo domain.RepoRef
    N    int
}
// Model.star holds: seq int, cov *core.Coverage, scans map[domain.RepoRef]renovate.RepoScan,
// view renovate.View, focus starBox, cursor [5]int, marked map[starTarget]bool

// dialog
type mergeOpts struct {
    strategy  string
    greenOnly func(domain.RepoRef) bool
    coverage  string   // warning line; "" for none
    skipped   []string // "owner/name #n: reason"
    star      bool     // confirm runs the ★ merge
}
func mergeDialog(ts []core.Target, o mergeOpts) *dialog

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

  1. make check passes, and every pre-existing UI test passes except the placeholder assertions this subtask updates.
  2. In demo mode at 80×24:
    • The ★ row is selected at startup, and the right column previews five titled boxes [1]…[5].
    • After the scan completes, [1] shows a totals line and one row per demo repo with Renovate PRs (homelab, infra, lazyforge), sorted by impact descending.
    • [2] shows postgres 16.4 → 17.0 (key docker+postgres+17.0) once, with ×2.
    • No line exceeds 80 columns, and the frame is exactly 24 lines.
  3. Partial results: when only some renovateScannedMsg have arrived, the boxes show the data received so far, and [1] shows scanned X of Y repositories with the current counts. A result with an old seq changes nothing. After a per-repo error, [1] shows · 1 failed and the status bar is unchanged.
  4. Moving the repo-list cursor from ★ to a repo cancels the scan context. Returning to ★ starts a new scan that seeds from cache (PeekRenovateScan) and fetches only misses. r on ★ starts a cached=false scan.
  5. m on a [2] group:
    • It issues a Service.Recheck for every member (deduped by repo and number), then opens a dialog listing each target as owner/name #n title, followed by every update the target carries. A batched PR lists all its packages.
    • The dialog shows the strategy line, plus the per-target CI warning or refusal note from each target repo's own RequiresGreenCI.
    • When coverage is incomplete, it shows <N> repositories not scanned; this may not be every PR for this update.
    • Confirm calls Service.Merge with exactly the rechecked, open, write-accessible targets, pinned to the rechecked head SHAs.
  6. Targets in a repo without write access are not passed to Merge and appear in the dialog as skipped with the forge.Can reason. When no target remains, no dialog opens and the status bar explains why.
  7. After a ★ merge, the dialog shows a per-target outcome (merged, refused with reason, failed, unknown or not started). Every non-merged target is ★-marked, merged PRs leave [2], [3] and [5] (rebuilt from Peek), and the status bar shows Merged k of n.
  8. space on a [3] or [5] PR toggles a mark that is visible in both boxes. m there merges the marked set across repos, or the cursor PR when nothing is marked. esc clears the marks.
  9. The regular-repo merge flow is unchanged: same targets, same dialog text for a single repo (no repo prefix), same results handling. The dialog now also lists the Renovate updates of regular-repo Renovate PRs, because core fills them.
  10. Details: l on a [1] row lists that repo's Renovate PRs, and l on a [2] row lists ecosystem, update type, Froms and members. A PR with a rejected body shows Couldn't read the update table. A dashboard lists its entries by section with checked state.
  11. Key hints and help come from the keymap. m is enabled only on [2], [3] and [5] rows with at least one mergeable target, and space only on [3] and [5]. No string in ui hardcodes "PR" or "MR" (box [3] uses HostInfo.ChangeRequestTerm).
  12. A hosted session passes the host's renovate_user to core (core.Options.RenovateUser). The new message types carry the fromSession() marker, so a replaced session's ★ results are dropped by the app's generation check.
  13. ui imports no adapter and calls no forge.Forge method. All I/O runs in tea.Cmds, and Update never blocks.

Shared constraints

  • Conventions. Follow the go-development skill:
    • layering ui → core → forge → domain, with core/renovate importing domain and stdlib only
    • no I/O in Update
    • typed …Msg messages carrying keys and an err field
    • styles only from internal/ui/style
    • bubbles/key bindings only, with no raw key strings in view code
    • errors wrapped with %w and context, and checked with errors.Is/errors.As
    • no panic, no fmt.Print from UI code, and no http.DefaultClient
  • Comments. Follow the comment-standard skill. Exported identifiers get doc comments. Other comments explain why.
  • Lint. No new modules. No //nolint without an engineering decision.
  • Layout. Every view renders correctly at 80×24. Layout comes from tea.WindowSizeMsg only.
  • Domain language. Code says ChangeRequest. UI text derives "PR"/"MR" from HostInfo.ChangeRequestTerm.
  • Merge contract (ADR 0011). Never bypass Service.Recheck/Service.Merge. Never merge without a head SHA from a recheck done in this m cycle. Never let the dialog bypass the core green-CI gate.
  • Test boundaries. No production code exists only for tests. Test fakes stay in forgetest.
  • Commits. type(scope): subject (feat, fix, chore, docs, refactor, test) with #10 in 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.
  • Branches. Branch feature/10-renovate-view off origin/develop. The MR targets develop.
  • docs/design.md is not edited.

Verification (Phase 7, whole repo)

  1. make check: format check, golangci-lint run, go test -race ./..., build.
  2. go list -deps ./internal/core/renovate | grep lazyforge/internal/ shows only internal/domain.
  3. Drive the demo at 80×24 in tmux: tmux new-session -d -x 80 -y 24 'go run ./cmd/lazyforge --demo', then tmux capture-pane -p. Check that:
    • the ★ preview has five boxes and [1] totals
    • l, then 2, shows the postgres group with ×2
    • m opens a dialog listing home/homelab #42 and home/infra #17, each with postgres 16.4 → 17.0 and infra's running-CI warning
    • esc cancels
    • every captured line is at most 80 columns
  • PQ1. What does [1] By repo count: PRs or updates?
    • (a) PRs, each in the bucket of its biggest bump. A repo row reads 2M 1m 3p = 6 PRs and matches the impact score, which is per PR.
    • (b) Updates (table rows). A batched "Go non-major" PR with five packages adds 5 to minor, so counts can exceed the PR count.
    • Recommended: (a). Planned as D7. If the PM picks (b), the change is confined to renovate.Build's counting.
🤖 **#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_point `6e08f15`. # Plan: #10 ★ Renovate view Planned against `develop` at 6e08f15 (MR #32 merged). Planner notes: `caveman:caveman`, `superpowers:brainstorming` and `superpowers:writing-plans` skills are unavailable in this session. Structured questioning and plan format were done by hand. `go-development` was 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. `m` on an update group merges every PR in it through the existing #9 merge contract and confirm dialog. ## Task type `feature` ## Size `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) - **Repo scope.** `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.** - **CI per PR.** `gitea.(*Forge).changeRequest` already fills `ChangeRequest.CI` from `GET …/commits/{sha}/status` for every listed PR. Forgejo reports running Actions as `pending` (mapped to `domain.CIPending`), so "running" can't be told apart from pending on Forgejo. **No new capability is needed.** - **Issues.** `Forge.ListIssues` and `core.Service.Issues` exist. They are enough for Dependency Dashboards. **No new capability is needed.** - **PR created date.** **Missing.** `domain.ChangeRequest` has `UpdatedAt` only, and the gitea `pull` struct doesn't decode `created_at` (it is in `internal/forge/gitea/testdata/pulls.json`). This must be added (S1). - **Merge contract.** `core.Service.Recheck(ctx, []Target) []Checked` and `core.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. - **Confirm dialog.** `internal/ui/dialog.go` `mergeDialog` assumes a single repo: it dedups by number only, uses one `greenOnly` bool and one strategy, and shows no repo prefix. It also already lists `cr.Renovate` updates under each target. That list has been empty so far because nothing fills `Renovate` yet. It must be generalized (S2). - **Coverage.** `core.Coverage` (`internal/core/coverage.go`) exists. It is single-goroutine, which fits UI ownership. - **Spike #2 fixtures** are already on develop at `internal/core/testdata/renovate/` (README, `prs.json`, 9 PR bodies, 3 dashboards). - **`SuggestRenovateUser`** lives in `internal/core/renovate.go` (package `core`). - **Session wiring.** `ui.App.openSession` (`internal/ui/app.go:283`) builds `core.Options`. `config.Host.RenovateUser` exists. Demo mode builds `core.Options{}` in `cmd/lazyforge/main.go`. Demo Renovate PRs use author `renovate` and branch `renovate/…`, and demo dashboards are titled `Dependency 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/renovate`** is pure. It imports `domain` and the stdlib only, with no I/O, no `forge` and no `core`. `core` imports it, and `ui` may import it for types and pure functions. This keeps the direction `ui → core(+core/renovate) → forge → domain`. Fixtures stay at `internal/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 != ""` and `strings.EqualFold(cr.Author, user)` - `strings.HasPrefix(cr.SourceBranch, "renovate/")` The rule is inclusive (OR), so a renamed bot account or a missing `renovate_user` never hides PRs. The `refs/pull/N/head` gotcha only affects closed PRs, and the scan lists open PRs only, so no adapter change is needed for it. - **D3. Core fills `ChangeRequest.Renovate`** in its fetch path. This covers `Service.ChangeRequests` before caching, and `Service.Recheck` on the fresh CR before it is written back and returned. It applies only to Renovate PRs (D2): the field becomes the `Parse` result, or `nil` when the body is rejected. Non-Renovate PRs keep `nil`. 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.Options` gains `RenovateUser string`. - **D4. Body parsing** follows spike #2: - Take the first Markdown table after the line `This PR contains the following updates:`. Map cells by header name. Stop at the first non-table line. - Required headers are `Package`, `Update` and `Change`. `Type` is optional. - **Refinement:** these documented Renovate `prBodyColumns` are 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. - Reject the whole body (`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. - Package cell forms are `[name](url)`, `[name](url) ([source](srcurl))`, or plain/backticked `name`. `Package` is the name. `SourceURL` is `srcurl` when present, else `url`, else `""`. - `UpdateType` is the Update cell lowercased. `DepType` is the Type cell or `""`. - **D5. Ecosystem inference.** It never comes from the body alone: - **Per-row Type cell:** `action` and `uses-with` map to `github-actions`. `final` and `stage` map to `docker`. `require`, `indirect` and `toolchain` map to `go`. `dependencies`, `devDependencies`, `peerDependencies` and `optionalDependencies` map to `npm`. - **No Type cell and exactly one row:** use the title noun, case-insensitive substring match. `docker tag`, `docker digest` and `docker image` map to `docker`. ` action ` (word) maps to `github-actions`. ` module ` maps to `go`. `helm release` maps to `helm`. - Anything else is `""` (unknown). - **D6. Grouping** for box `[2]`: - A Renovate PR with exactly one parsed update and a known ecosystem joins the group keyed by `GroupKey{Ecosystem, Package, To}`, following the PM decision. - A PR with more than one update is **batched**. It gets its own row, labelled with its title, and always merges whole. - A PR with one update and an unknown ecosystem gets its own row, labelled `package → to`, and joins no group. - A PR whose body was rejected (`Renovate == nil`) is not in `[2]` at all. It still appears in `[1]`, `[3]` and `[5]`. - `From` is not in the key. A group carries its distinct sorted `Froms`. The group's `UpdateType` is the highest rank among its members (major > minor > patch > anything else). - Rows sort by member count (descending), then `Label` (ascending), then the first member's repo string. - **D7. Counting and impact** for box `[1]`: - Each Renovate PR counts once, in the bucket of its highest-ranked update type: Major, Minor, Patch, or Other. Other covers digest, pin, unknown and rejected bodies. So `Major+Minor+Patch+Other == PRs`. - Impact per PR is `1 + log2(1 + days)`, where `days = max(now.Sub(cr.CreatedAt), 0).Hours()/24` as a fractional value. A zero `CreatedAt` counts as 0 days, which scores 1. - A repo's impact is the sum over its Renovate PRs. Majors aren't weighted extra. - Repos with no Renovate PRs are omitted. Rows sort by impact (descending), then repo string (ascending). Totals are the sums. - Count unit, see PQ1. - **D8. Box `[5]`** holds Renovate PRs whose `!cr.CI.Green()`: pending, running, fail and cancelled. On Forgejo, running shows as pending. - **D9. Dashboards.** - An issue is a dashboard when either of these holds: - its trimmed title equals `Dependency Dashboard` (EqualFold) - `user != ""`, the author matches `user` (EqualFold), and the body contains `This issue lists Renovate updates` - `ParseDashboard(body)` returns one `Entry` per line matching `^\s*- \[( |x|X)\] `: - `Section` is the text of the most recent `## ` heading. - `Checked` is true for `x`/`X`. - `Branch` is from the first `<!-- <word>-branch=<value> -->`, or `""`. - `Title` is the remaining text with HTML comments removed, `[t](u)` reduced to `t`, and surrounding space trimmed. - Parsing stops at a `## Detected Dependencies` heading. - Ticking stays out of scope. - **D10. Scan model.** - The UI issues **one command per repo**, and each calls `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. - The UI owns a `core.Coverage` and a per-repo `RepoScan` map, and calls the pure `renovate.Build` after every result. This gives partial results while loading. - Each scan has a sequence number. Results from an older scan are dropped. The scan runs on its own context, which is cancelled when the cursor leaves the ★ row (navigating away cancels, per go-development). - `cached=true` (entering ★): `PeekRenovateScan` hits seed data and count as scanned, and only misses are fetched. - `cached=false` (`r` or 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.Recheck` and `Service.Merge` unchanged: - Targets are deduped by (repo, number). - Targets in repos where `svc.Can(forge.ActMerge, repo)` fails are excluded and listed in the dialog as skipped, with the reason. - After the merge, every target that didn't merge stays or becomes ★-marked, and merged ones are unmarked (#9 contract). - The dialog is generalized, not forked. - **D12. No new dependencies.** No change to `forge.Forge` beyond 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** 1. `internal/domain/domain.go`: add `CreatedAt time.Time` to `ChangeRequest`, after `UpdatedAt`. 2. `internal/forge/gitea/forge.go`: - Decode `created_at` on `pull`. - Map it to `ChangeRequest.CreatedAt` in `changeRequest`. 3. `internal/forge/forgetest/demo.go`: - Give every demo CR a non-zero `CreatedAt` that is at or before `UpdatedAt`. 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. - Do **not** change repo, PR, issue or run counts or numbers. `demo_test.go` and `demo_ac_test.go` pin them. - Changing a demo PR body (for example making lazyforge#3 a two-row batched body) is allowed if those tests still pass, or are adjusted in this domain. 4. New package `internal/core/renovate` holds D2, D4 to D9 and `Build`. It may be split over several files. 5. `internal/core/service.go` and `internal/core/actions.go`: - Add `Options.RenovateUser`. - Fill `Renovate` in `ChangeRequests` before caching, and in `Recheck` on the fresh CR before the cache update and return (D3). 6. `internal/core/renovate.go`: - Add `Service.RenovateScan` and `Service.PeekRenovateScan`. - Keep `SuggestRenovateUser` unchanged. 7. `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`, Type `require`, minor/patch. - `digest-docker.md`: one row, Update `digest`, backticked short SHAs. - `confidence-columns.md`: `Package | Change | Age | Adoption | Passing | Confidence` plus `Update`, with badge image cells. - `rejected-unknown-column.md`: an extra `Foo` column. - `rejected-no-arrow.md`: Change cell without `→`. 8. `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. 9. `docs/architecture.md`: - Add `CreatedAt` to the domain-model block. - Mention `internal/core/renovate` under Layers or Caching. - Add a `RenovateScan` and `Coverage` sentence in "Caching and refresh". - Link ADR 0013. - Leave design.md untouched. **File domain** (exclusive) - `internal/domain/domain.go` - `internal/forge/gitea/forge.go` - `internal/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.go` - `internal/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.md` **Seams** (all exported; tests target these) ```go // internal/domain type ChangeRequest struct { /* … */ CreatedAt time.Time /* … */ } // internal/core/renovate func IsRenovate(cr domain.ChangeRequest, user string) bool func Parse(title, body string) (updates []domain.RenovateUpdate, ok bool) func IsDashboard(is domain.Issue, user string) bool type Entry struct { Section, Title, Branch string Checked bool } func ParseDashboard(body string) []Entry func Impact(opened, now time.Time) float64 type Member struct { Repo domain.RepoRef CR domain.ChangeRequest // CR.Renovate filled, nil when the body was rejected } type GroupKey struct{ Ecosystem, Package, To string } type Group struct { Key GroupKey // zero value for batched and unknown-ecosystem rows Batched bool // the single member carries more than one update Label string // "package → to", or the PR title when Batched UpdateType string // highest-ranked update type among members Froms []string // distinct, sorted; nil when Batched Members []Member } type RepoSummary struct { Repo domain.RepoRef // zero for host totals PRs, Major, Minor, Patch, Other int Impact float64 } type Dashboard struct { Repo domain.RepoRef Issue domain.Issue Entries []Entry } type RepoScan struct { Repo domain.Repo // full repo, so the UI can check access and merge style PRs []domain.ChangeRequest // Renovate PRs only (D2), Renovate field filled per D3 Dashboards []Dashboard } type View struct { Totals RepoSummary ByRepo []RepoSummary // [1] Groups []Group // [2] PRs []Member // [3] Dashboards []Dashboard // [4] CI []Member // [5] } func Build(scans []RepoScan, now time.Time) View // internal/core type Options struct { /* existing */ RenovateUser string } func (s *Service) RenovateScan(ctx context.Context, r domain.Repo) (renovate.RepoScan, error) func (s *Service) PeekRenovateScan(r domain.Repo) (renovate.RepoScan, bool) // existing, behavior extended by D3: func (s *Service) ChangeRequests(ctx context.Context, r domain.RepoRef) ([]domain.ChangeRequest, error) func (s *Service) Recheck(ctx context.Context, ts []Target) []Checked ``` `Build` ordering: - `PRs` and `CI` sort by `CreatedAt` ascending (oldest first), then repo string, then number. - `Dashboards` sort by repo string. - `ByRepo` and `Groups` follow D7 and D6. - `Build` must be deterministic for a given input regardless of `scans` order. **Acceptance criteria** 1. `go build ./...` passes, and existing tests pass (`make check`). 2. `IsRenovate` follows D2: - Matching author with any branch is true. - Any author with a `renovate/` branch is true. - Author matching is case-insensitive. - An empty `user` relies on the branch rule only. 3. `Parse` on every captured PR fixture in `internal/core/testdata/renovate/` (every `prs.json` entry with a non-dashboard category) returns `ok=true` with one update per table row. - `multi-manager-major_forgejo_9.md` gives two `node` updates. The first has ecosystem `github-actions`, DepType `uses-with`, `20`→`24`. The second has ecosystem `docker`, DepType `final`, `22-bookworm`→`24-bookworm`. - `github-actions-major_home_2.md` gives `actions/checkout`, `github-actions`, `major`, `v4`→`v7`. - `docker-compose-major_adventure_2.md` gives ecosystem `docker` from the title noun `Docker tag`. - `docker-group-nonmajor_forgejo_13.md` gives package `codeberg.org/forgejo/forgejo`, `SourceURL` `https://codeberg.org/forgejo/forgejo`, patch `16.0.3`→`16.0.5`. 4. `Parse` returns `ok=false` and nil for each of these: - all three dashboard fixtures - `rejected-unknown-column.md` - `rejected-no-arrow.md` - a body without the intro line - a row-width mismatch - a table with zero rows `Parse` accepts `confidence-columns.md`, `batched-gomod-nonmajor.md` (3 updates, ecosystem `go`) and `digest-docker.md`. 5. Ecosystem: a single-row body with no Type column and a title like `Update dependency node to v24` yields `Ecosystem == ""`. 6. `ParseDashboard(dashboard_jellyfin_3.md)` returns exactly one entry: Section `Awaiting Schedule`, unchecked, Branch `renovate/docker-images-non-major`, Title `Update jellyfin/jellyfin Docker tag to v12.1`. Nothing comes from the Detected Dependencies section. `IsDashboard` is true for issues titled `Dependency Dashboard` and false for an ordinary issue. 7. `Impact` returns 1 at 0 days, 2 at 1 day, 4 at 7 days, and 1 for a zero or future `opened`. 8. `Build` follows D6 to D8: - `actions/checkout` v3→v7 in repo A and v4→v7 in repo B (both `github-actions`) form one group with 2 members and `Froms ["v3","v4"]`. - `node`→24 with ecosystem `docker` and `node`→24 with ecosystem `github-actions` form two groups. - A batched PR forms its own `Batched` row with 1 member. - A rejected PR is absent from `Groups` but present in `PRs` and counted in `ByRepo` under Other. - `CI` holds exactly the PRs with non-green CI. - `ByRepo` sorts by impact, and `Totals` sums the rows. 9. `Service.ChangeRequests` returns Renovate PRs with `Renovate` filled (or nil when rejected) and non-Renovate PRs with nil. `PeekChangeRequests` returns the same filled values. `Recheck` returns a re-parsed `Renovate` when the body changed on the forge. 10. `RenovateScan` returns 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. `PeekRenovateScan` reports `ok=false` unless both the CR and issue lists are cached. 11. The gitea adapter maps `created_at` to `CreatedAt` (checked against `testdata/pulls.json`), and every demo CR has a non-zero `CreatedAt`. 12. `internal/core/renovate` imports only `domain` and stdlib packages (`go list -deps` shows no `internal/forge`, `internal/core`, `internal/ui` or `internal/config`). 13. 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** 1. **Session wiring** (`app.go`): - `openSession` passes `RenovateUser: live.Load().Hosts[name].RenovateUser` in `core.Options`. - Add `fromSession()` markers for every new session message type. 2. **★ sub-model** in a new file, for example `internal/ui/renovate.go`. It holds: - the scan sequence, scan context and cancel - a `*core.Coverage` - a `map[domain.RepoRef]renovate.RepoScan` - the current `renovate.View` (recomputed in `Update`, never in `View`) - five cursors, the focus, and marks keyed by (repo, number) It renders the five boxes with the existing `frame`, `row`, `splitHeights` and `style` helpers. 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 CI` 3. **Rows**, each truncated to width: - `[1]`: - While coverage is incomplete, the first line is `scanned X of Y repositories`, with `· N failed` in the error style when failures exist. - Next is the host totals line: `All · <PRs> · <M>M <m>m <p>p · <impact %.1f>`. - Then one row per repo: `owner/name <M>M <m>m <p>p <impact>`. - `[2]`: `Label`, then `Froms` for non-batched rows. The meta shows `×N` when N > 1, the `UpdateType`, and one CI icon per member. - `[3]` and `[5]`: a mark glyph when marked, then `owner/name #n`, then `package from → to` for 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 as `N pending`. 4. **Navigation**: - With the repo-list cursor on ★, the right column previews the five boxes. This replaces the "isn't built yet" placeholder. - `l` or `1`–`5` enters ★ boxes. `j/k/gg/G/tab/shift-tab/1-5` work as in regular boxes, and `h` goes back. - `l` on a row opens details. Details for a `[1]` row list that repo's Renovate PRs, which is how `l` "shows that repo's Renovate PRs". - Details for a group list the ecosystem, update type, Froms, and every member (`owner/name #n from → to` plus CI). - Details for a PR show the existing overview plus its updates, or `Couldn't read the update table` when `Renovate` is nil. - Details for a dashboard list entries by section with ☐/☑. - The breadcrumb reads `host › ★ Renovate › [k] <title> › <item>`. 5. **Loading** (D10): - The scan starts when the repo list has loaded and ★ is selected, which includes startup, since the cursor starts on ★. It also starts when the cursor returns to ★. - Moving the repo-list cursor off ★ cancels the scan context. - `r` and the five-minute tick while ★ is selected rescan with `cached=false`. - Results with a stale sequence are dropped. `context.Canceled` is silent. A per-repo error goes to `Coverage.Done(repo, err)` and does not go to the status bar, because the `[1]` coverage line reports it. 6. **Actions in ★**: - `space` marks or unmarks a PR row in `[3]` or `[5]`. Marks are shared across both boxes and keyed by (repo, number). `esc` clears them. - `m`: - On `[2]` it targets every member of the group under the cursor, ignoring marks. - On `[3]` or `[5]` it targets the marked PRs when any are marked, else the PR under the cursor. - On `[1]` or `[4]` it is disabled. - Targets go through `Service.Recheck` first. Then the dialog opens with: - the still-open targets - skipped targets: no write access per `svc.Can(forge.ActMerge, repo)`, or not open any more - for group merges only, when `!Coverage.Complete()`, the warning `<N> repositories not scanned; this may not be every PR for this update`, where N is unscanned plus failed - Confirm runs `Service.Merge`. Results show per target. Unmerged targets become ★-marked, and merged ones unmark. - After a recheck, a merge, or another action, affected repos reseed from `PeekRenovateScan` and `View` is rebuilt. - `o` opens the PR or dashboard web URL. `a`, `x` and `c` on PR rows, and `x` and `c` on dashboard rows, reuse the existing approve, close-dialog and comment commands with an `ItemRef` built from the row's repo. Key enablement follows `svc.Can(…, rowRepo)`. - The ★ path never calls `loadBoxes` or `selectRepo` with a zero `RepoRef`. 7. **Dialog generalization** (`dialog.go`). `mergeDialog` takes the targets plus an options value carrying: - a strategy string: `"" → "repo default"`, and a multi-repo target set shows `each repo's default` - a per-repo green-only predicate, replacing the single bool - a coverage warning string - skipped lines - a flag that routes confirm to the ★ merge It dedups by (repo, number). It prefixes each target with `owner/name` when targets span more than one repo, or when opened from ★. It lists every update each target carries (existing loop), or `packages unknown` for a Renovate target with nil `Renovate`. The regular-repo merge path keeps its behavior and output. Update the existing caller in `actions.go`. 8. **Existing tests that assert the placeholder** (`model_test.go` around 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`, new `renovate*.go`, and existing `*_test.go` only 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/` or `docs/`. 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 `Update` with a real `core.Service` over `forgetest.NewDemo` or `forgetest.Fake`, and asserts state, commands and ANSI-stripped `View` text per ADR 0010) ```go // messages (each has a fromSession() marker in app.go) type renovateScannedMsg struct { seq int repo domain.RepoRef scan renovate.RepoScan err error } type starRecheckedMsg struct { results []core.Checked group bool // the targets came from a [2] group, so the coverage warning applies } type starMergeDoneMsg struct{ results []core.MergeResult } // ★ boxes, in order const ( starByRepo starBox = iota // [1] starGroups // [2] starPRs // [3] starDashboards // [4] starCI // [5] ) type starTarget struct { Repo domain.RepoRef N int } // Model.star holds: seq int, cov *core.Coverage, scans map[domain.RepoRef]renovate.RepoScan, // view renovate.View, focus starBox, cursor [5]int, marked map[starTarget]bool // dialog type mergeOpts struct { strategy string greenOnly func(domain.RepoRef) bool coverage string // warning line; "" for none skipped []string // "owner/name #n: reason" star bool // confirm runs the ★ merge } func mergeDialog(ts []core.Target, o mergeOpts) *dialog ``` 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** 1. `make check` passes, and every pre-existing UI test passes except the placeholder assertions this subtask updates. 2. In demo mode at 80×24: - The ★ row is selected at startup, and the right column previews five titled boxes `[1]`…`[5]`. - After the scan completes, `[1]` shows a totals line and one row per demo repo with Renovate PRs (homelab, infra, lazyforge), sorted by impact descending. - `[2]` shows `postgres 16.4 → 17.0` (key docker+postgres+17.0) once, with `×2`. - No line exceeds 80 columns, and the frame is exactly 24 lines. 3. Partial results: when only some `renovateScannedMsg` have arrived, the boxes show the data received so far, and `[1]` shows `scanned X of Y repositories` with the current counts. A result with an old `seq` changes nothing. After a per-repo error, `[1]` shows `· 1 failed` and the status bar is unchanged. 4. Moving the repo-list cursor from ★ to a repo cancels the scan context. Returning to ★ starts a new scan that seeds from cache (`PeekRenovateScan`) and fetches only misses. `r` on ★ starts a `cached=false` scan. 5. `m` on a `[2]` group: - It issues a `Service.Recheck` for every member (deduped by repo and number), then opens a dialog listing each target as `owner/name #n title`, followed by every update the target carries. A batched PR lists all its packages. - The dialog shows the strategy line, plus the per-target CI warning or refusal note from each target repo's own `RequiresGreenCI`. - When coverage is incomplete, it shows `<N> repositories not scanned; this may not be every PR for this update`. - Confirm calls `Service.Merge` with exactly the rechecked, open, write-accessible targets, pinned to the rechecked head SHAs. 6. Targets in a repo without write access are not passed to `Merge` and appear in the dialog as skipped with the `forge.Can` reason. When no target remains, no dialog opens and the status bar explains why. 7. After a ★ merge, the dialog shows a per-target outcome (merged, refused with reason, failed, unknown or not started). Every non-merged target is ★-marked, merged PRs leave `[2]`, `[3]` and `[5]` (rebuilt from Peek), and the status bar shows `Merged k of n`. 8. `space` on a `[3]` or `[5]` PR toggles a mark that is visible in both boxes. `m` there merges the marked set across repos, or the cursor PR when nothing is marked. `esc` clears the marks. 9. The regular-repo merge flow is unchanged: same targets, same dialog text for a single repo (no repo prefix), same results handling. The dialog now also lists the Renovate updates of regular-repo Renovate PRs, because core fills them. 10. Details: `l` on a `[1]` row lists that repo's Renovate PRs, and `l` on a `[2]` row lists ecosystem, update type, Froms and members. A PR with a rejected body shows `Couldn't read the update table`. A dashboard lists its entries by section with checked state. 11. Key hints and help come from the keymap. `m` is enabled only on `[2]`, `[3]` and `[5]` rows with at least one mergeable target, and `space` only on `[3]` and `[5]`. No string in `ui` hardcodes "PR" or "MR" (box `[3]` uses `HostInfo.ChangeRequestTerm`). 12. A hosted session passes the host's `renovate_user` to core (`core.Options.RenovateUser`). The new message types carry the `fromSession()` marker, so a replaced session's ★ results are dropped by the app's generation check. 13. `ui` imports no adapter and calls no `forge.Forge` method. All I/O runs in `tea.Cmd`s, and `Update` never blocks. ## Shared constraints - **Conventions.** Follow the `go-development` skill: - layering `ui → core → forge → domain`, with `core/renovate` importing domain and stdlib only - no I/O in `Update` - typed `…Msg` messages carrying keys and an `err` field - styles only from `internal/ui/style` - `bubbles/key` bindings only, with no raw key strings in view code - errors wrapped with `%w` and context, and checked with `errors.Is`/`errors.As` - no `panic`, no `fmt.Print` from UI code, and no `http.DefaultClient` - **Comments.** Follow the `comment-standard` skill. Exported identifiers get doc comments. Other comments explain why. - **Lint.** No new modules. No `//nolint` without an engineering decision. - **Layout.** Every view renders correctly at 80×24. Layout comes from `tea.WindowSizeMsg` only. - **Domain language.** Code says `ChangeRequest`. UI text derives "PR"/"MR" from `HostInfo.ChangeRequestTerm`. - **Merge contract (ADR 0011).** Never bypass `Service.Recheck`/`Service.Merge`. Never merge without a head SHA from a recheck done in this `m` cycle. Never let the dialog bypass the core green-CI gate. - **Test boundaries.** No production code exists only for tests. Test fakes stay in `forgetest`. - **Commits.** `type(scope): subject` (`feat`, `fix`, `chore`, `docs`, `refactor`, `test`) with `#10` in 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. - **Branches.** Branch `feature/10-renovate-view` off `origin/develop`. The MR targets `develop`. - `docs/design.md` is not edited. ## Verification (Phase 7, whole repo) 1. `make check`: format check, `golangci-lint run`, `go test -race ./...`, build. 2. `go list -deps ./internal/core/renovate | grep lazyforge/internal/` shows only `internal/domain`. 3. Drive the demo at 80×24 in tmux: `tmux new-session -d -x 80 -y 24 'go run ./cmd/lazyforge --demo'`, then `tmux capture-pane -p`. Check that: - the ★ preview has five boxes and `[1]` totals - `l`, then `2`, shows the postgres group with ×2 - `m` opens a dialog listing `home/homelab #42` and `home/infra #17`, each with `postgres 16.4 → 17.0` and infra's running-CI warning - `esc` cancels - every captured line is at most 80 columns ## Product questions (non-blocking; work proceeds on the recommended default) - **PQ1. What does `[1]` By repo count: PRs or updates?** - (a) PRs, each in the bucket of its biggest bump. A repo row reads `2M 1m 3p` = 6 PRs and matches the impact score, which is per PR. - (b) Updates (table rows). A batched "Go non-major" PR with five packages adds 5 to minor, so counts can exceed the PR count. - **Recommended: (a).** Planned as D7. If the PM picks (b), the change is confined to `renovate.Build`'s counting.
Author
Owner

🤖 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 patch means 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.

🤖 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 patch` means 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.
Author
Owner

🤖 MR #33 open: reviews passed, tests backfilled. Merging once CI is green.

🤖 MR #33 open: reviews passed, tests backfilled. Merging once CI is green.
Author
Owner

🤖 merged in #33

🤖 merged in #33
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
deadstyle/lazyforge#10
No description provided.