~bigbes/sr-ht-spec

ref: c74776077006e81af82c2fd53cb186271b42bee9 sr-ht-spec/service/proposals.go -rw-r--r-- 4.7 KiB
c7477607 — Eugene Blikh authn: accept tokens.sr.ht working tokens beside the agent token 10 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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 <origin>/~owner/space/p/<id>, 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
}