package service import ( "context" "errors" "fmt" "time" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/db" ) // Proposal is one proposal as the surfaces above this layer need it: core // types, its space named by reference rather than by the row's opaque id, and // nothing from db/ or gitx/ leaking through. // // It is the read shape shared by every surface — GraphQL's `proposals` field, // the MCP and REST write responses, the review page — so that "what a proposal // is" has one spelling above service/. db.Proposal is the storage shape and // stays in db/; the mapping between them is proposalView, in this package, // because the dependency rule keeps db/ types out of every caller. type Proposal struct { ID int Space core.SpaceRef Title string Rationale string BaseRev string Branch string State core.ProposalState Approval core.Approval MergedRev string Agent string AgentSession string Created time.Time Resolved *time.Time } // ProposalURL is the stable, shareable link to a proposal: the value every // write response hands back so an agent can surface it in its transcript. // // The form is /~owner/space/p/, and it is built here rather than in // each surface because the origin is service/'s config and a second surface // spelling the path would be a link that resolves on one door and 404s on // another. It resolves after merge or rejection too — proposal URLs outlive the // branch — so it is the same URL whatever the proposal's state. func (s *Service) ProposalURL(ref core.SpaceRef, id int) string { return fmt.Sprintf("%s/~%s/%s/p/%d", s.cfg.Origin, ref.Owner, ref.Name, id) } // proposalView maps a stored proposal onto the surface shape, naming its space // by the reference the caller already resolved rather than re-reading the row's // space_id. func proposalView(p *db.Proposal, ref core.SpaceRef) Proposal { return Proposal{ ID: p.ID, Space: ref, 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, } } // ListProposals returns a space's proposals in one state, newest first. // // This is the read the GraphQL `proposals` field, the inbox and the review UI // all call — the space-scoped listing the design puts in the read schema. It // exists here, in service/, because nothing above this layer may query db/ // directly: the port graph/ declared and left nil until Phase 3 is this // method. // // The space is resolved by reference to its row so the listing filters by // space_id, and a space that does not exist is ErrNotFound rather than an empty // list — "no such space" and "this space has an empty queue" are different // answers, and a surface that conflated them would tell a reviewer their queue // is clear when the space name was simply wrong. func (s *Service) ListProposals(ctx context.Context, ref core.SpaceRef, state core.ProposalState) ([]Proposal, error) { if _, err := core.ParseProposalState(string(state)); err != nil { return nil, err } row, err := s.store.GetSpace(ctx, ref) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, fmt.Errorf("%w: space %s", ErrNotFound, ref) } return nil, fmt.Errorf("service: look up space %s: %w", ref, err) } rows, err := s.store.ListProposalsBySpace(ctx, row.ID, state, 0) if err != nil { return nil, fmt.Errorf("service: list %s proposals of %s: %w", state, ref, err) } out := make([]Proposal, 0, len(rows)) for _, p := range rows { out = append(out, proposalView(p, ref)) } return out, nil } // GetProposal resolves one proposal by id, naming its space by reference. // // It is the lookup the stable proposal URL resolves through, so it works in // every state: a link to a merged or rejected proposal still shows the outcome. // The space is resolved from the row's space_id back to a reference so no // caller above this layer has to hold the opaque id. func (s *Service) GetProposal(ctx context.Context, id int) (Proposal, error) { row, err := s.store.GetProposal(ctx, id) if err != nil { if errors.Is(err, db.ErrNotFound) { return Proposal{}, fmt.Errorf("%w: proposal %d", ErrNotFound, id) } return Proposal{}, fmt.Errorf("service: look up proposal %d: %w", id, err) } space, err := s.store.GetSpaceByID(ctx, row.SpaceID) if err != nil { return Proposal{}, fmt.Errorf("service: resolve space %d of proposal %d: %w", row.SpaceID, id, err) } return proposalView(row, space.Ref), nil }