@@ 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()
@@ 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)
@@ 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)
+ }
+}