From f8a7a1743ad9704ff46f9e9319f8cc22b75bbdef Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 23 Jul 2026 08:18:21 +0300 Subject: [PATCH] feat(graph): wire the proposals read to service.ListProposals (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Proposals port declared a proposal listing and left Options.Proposals nil, so the `proposals` query failed loudly with "service/ exposes no proposal listing yet". Phase 3 supplies it: an adapter maps service.Proposal onto graph.Proposal at the edge — the two structs are identical, but service/ must not import graph/, so the rename lives here beside web.NewReader's equivalent — and cmd wires graph.NewProposals(svc) into the schema. The `proposals` field now answers from service/. --- cmd/specsrht/main.go | 7 ++-- graph/proposals.go | 63 +++++++++++++++++++++++++++++ graph/proposals_test.go | 87 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 graph/proposals.go create mode 100644 graph/proposals_test.go diff --git a/cmd/specsrht/main.go b/cmd/specsrht/main.go index d6031e5bb11480266e3cb1c4ab5a2332666ed07c..681125dbb0d8aa150506aef2ae265ae14d3003a7 100644 --- a/cmd/specsrht/main.go +++ b/cmd/specsrht/main.go @@ -478,9 +478,10 @@ func newSurfaces(conf ini.File, cfg service.Config, svc *service.Service, versio } gql, err := graph.New(graph.Options{ - Reader: svc, - Searcher: index, - Resolver: svc.Resolver(), + Reader: svc, + Searcher: index, + Proposals: graph.NewProposals(svc), + Resolver: svc.Resolver(), }) if err != nil { index.Close() diff --git a/graph/proposals.go b/graph/proposals.go new file mode 100644 index 0000000000000000000000000000000000000000..59d53774574c8107bc46116d88bb804b472142e3 --- /dev/null +++ b/graph/proposals.go @@ -0,0 +1,63 @@ +package graph + +import ( + "context" + + "sourcecraft.dev/bigbes/sr-ht-spec/core" + "sourcecraft.dev/bigbes/sr-ht-spec/service" +) + +// proposalLister is the service-layer proposal read this package adapts. It is +// the method *service.Service grew in Phase 3, named as an interface here so the +// adapter is testable without a whole Service and so this file states exactly +// what it depends on. +type proposalLister interface { + ListProposals(ctx context.Context, space core.SpaceRef, state core.ProposalState) ([]service.Proposal, error) +} + +// serviceProposals adapts the service layer to the Proposals port. +// +// The port yields graph.Proposal (core types, no db/ leak) while the service +// yields service.Proposal, and the two cannot be one type without a dependency +// cycle — service/ must not import graph/. So the mapping lives here, at the +// edge, exactly as web.NewReader adapts the same service to the web surface's +// read shape. It is a field-for-field copy; the two structs are deliberately +// identical so this stays a rename rather than a translation. +type serviceProposals struct{ svc proposalLister } + +// NewProposals wires the service layer into the schema's proposals field, +// closing the gap the Proposals port documented: with this set, Options.Proposals +// is no longer nil and the `proposals` query answers from service/ instead of +// failing with "no proposal listing yet". +func NewProposals(svc proposalLister) Proposals { return serviceProposals{svc: svc} } + +func (s serviceProposals) ListProposals(ctx context.Context, space core.SpaceRef, state core.ProposalState) ([]Proposal, error) { + ps, err := s.svc.ListProposals(ctx, space, state) + if err != nil { + return nil, err + } + out := make([]Proposal, 0, len(ps)) + for _, p := range ps { + out = append(out, Proposal{ + ID: p.ID, + Space: p.Space, + Title: p.Title, + Rationale: p.Rationale, + BaseRev: p.BaseRev, + Branch: p.Branch, + State: p.State, + Approval: p.Approval, + MergedRev: p.MergedRev, + Agent: p.Agent, + AgentSession: p.AgentSession, + Created: p.Created, + Resolved: p.Resolved, + }) + } + return out, nil +} + +// Compile-time assertion that the production service satisfies the read this +// adapter needs. It lives here, beside the assertions in resolver.go, so a +// signature drift in service/ breaks the build rather than a test. +var _ proposalLister = (*service.Service)(nil) diff --git a/graph/proposals_test.go b/graph/proposals_test.go new file mode 100644 index 0000000000000000000000000000000000000000..beedb3ac51c26aef38e61f660fcdb51b2c2ea9af --- /dev/null +++ b/graph/proposals_test.go @@ -0,0 +1,87 @@ +package graph + +import ( + "context" + "testing" + "time" + + "sourcecraft.dev/bigbes/sr-ht-spec/core" + "sourcecraft.dev/bigbes/sr-ht-spec/service" +) + +// stubLister is a proposalLister that returns a fixed row, so the adapter's +// field mapping can be checked without a Service, a database or a repository. +type stubLister struct { + rows []service.Proposal + gotSpace core.SpaceRef + gotState core.ProposalState +} + +func (s *stubLister) ListProposals(_ context.Context, space core.SpaceRef, state core.ProposalState) ([]service.Proposal, error) { + s.gotSpace = space + s.gotState = state + return s.rows, nil +} + +// TestNewProposalsMapsEveryField proves the adapter is a faithful rename: every +// field of a service proposal reaches the graph shape unchanged, and the query +// arguments are forwarded verbatim. A dropped field here would silently blank a +// column in the read schema. +func TestNewProposalsMapsEveryField(t *testing.T) { + ref := core.SpaceRef{Owner: "bigbes", Name: "rfcs"} + created := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) + resolved := created.Add(time.Hour) + want := service.Proposal{ + ID: 42, + Space: ref, + Title: "Add a note", + Rationale: "because", + BaseRev: "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809", + Branch: "proposals/42", + State: core.StateMerged, + Approval: core.ApprovalPolicy, + MergedRev: "2a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d", + Agent: "claude-code/spec-writer", + AgentSession: "sess-1", + Created: created, + Resolved: &resolved, + } + stub := &stubLister{rows: []service.Proposal{want}} + + got, err := NewProposals(stub).ListProposals(context.Background(), ref, core.StateMerged) + if err != nil { + t.Fatalf("ListProposals: %v", err) + } + if stub.gotSpace != ref || stub.gotState != core.StateMerged { + t.Fatalf("forwarded (%v, %s), want (%v, merged)", stub.gotSpace, stub.gotState, ref) + } + if len(got) != 1 { + t.Fatalf("got %d proposals, want 1", len(got)) + } + g := got[0] + mismatches := []struct { + field string + got, want any + }{ + {"ID", g.ID, want.ID}, + {"Space", g.Space, want.Space}, + {"Title", g.Title, want.Title}, + {"Rationale", g.Rationale, want.Rationale}, + {"BaseRev", g.BaseRev, want.BaseRev}, + {"Branch", g.Branch, want.Branch}, + {"State", g.State, want.State}, + {"Approval", g.Approval, want.Approval}, + {"MergedRev", g.MergedRev, want.MergedRev}, + {"Agent", g.Agent, want.Agent}, + {"AgentSession", g.AgentSession, want.AgentSession}, + {"Created", g.Created, want.Created}, + } + for _, m := range mismatches { + if m.got != m.want { + t.Errorf("%s = %v, want %v", m.field, m.got, m.want) + } + } + if g.Resolved == nil || !g.Resolved.Equal(resolved) { + t.Errorf("Resolved = %v, want %v", g.Resolved, resolved) + } +}