tinyrelayfile
tinyrelay/internal/daemon/collaboration_approvals.go/

internal/daemon/collaboration_approvals.go raw
1package daemon
2
3// Repository proposals. An issue, pull request, patch or comment whose
4// author holds an agent grant with propose on that repository is a
5// proposal: it stays pending until the repository owner, one of its
6// maintainers, the relay owner or a moderator reacts to the event id with
7// "+", which approves it, or "-", which rejects it. The newest deciding
8// reaction counts; reactions from members and from the author do not. An
9// updated event is a new id and starts pending again. A pending or
10// rejected proposal is visible only to those deciders and its author; for
11// everyone else it does not exist. The state is computed here and carried
12// on every browse result that lists repository conversations.
13
14import (
15 "context"
16 "strings"
17 "time"
18
19 "github.com/FelineStateMachine/tinyrelay/internal/community"
20 "github.com/FelineStateMachine/tinyrelay/internal/event"
21 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
22 "github.com/FelineStateMachine/tinyrelay/internal/storage"
23)
24
25// collaborationProposal is the proposal state carried on a listed or shown
26// item. Proposal marks an event from an agent whose grant says propose on
27// the repository. Approval is pending, approved or rejected; the other
28// fields name the deciding reaction, its time and its author once one
29// exists.
30type collaborationProposal struct {
31 Proposal bool `json:"proposal,omitempty"`
32 Approval string `json:"approval,omitempty"`
33 ApprovalEvent string `json:"approval_event,omitempty"`
34 ApprovalAt int64 `json:"approval_at,omitempty"`
35 ApprovalBy string `json:"approval_by,omitempty"`
36}
37
38// collaborationRoles caches the role and grant lookups one browse call
39// makes for a repository, since a thread's events and their reactions
40// repeat the same few keys.
41type collaborationRoles struct {
42 t *Tenant
43 repo gitrelay.Repository
44 roles map[string]string
45 grants map[string]*community.AgentGrant
46 proposers map[string]bool
47 deciders map[string]bool
48}
49
50func (t *Tenant) collaborationRoles(r gitrelay.Repository) *collaborationRoles {
51 return &collaborationRoles{t: t, repo: r, roles: map[string]string{}, grants: map[string]*community.AgentGrant{}, proposers: map[string]bool{}, deciders: map[string]bool{}}
52}
53
54func (r *collaborationRoles) role(ctx context.Context, pubkey string) (string, error) {
55 if role, ok := r.roles[pubkey]; ok {
56 return role, nil
57 }
58 role, err := r.t.community.Role(ctx, pubkey)
59 if err != nil {
60 return "", err
61 }
62 r.roles[pubkey] = role
63 return role, nil
64}
65
66// grant returns the agent grant recorded for a key, or nil when there is
67// none.
68func (r *collaborationRoles) grant(ctx context.Context, pubkey string) (*community.AgentGrant, error) {
69 if grant, ok := r.grants[pubkey]; ok {
70 return grant, nil
71 }
72 grant, ok, err := r.t.community.AgentGrant(ctx, pubkey)
73 if err != nil {
74 return nil, err
75 }
76 var recorded *community.AgentGrant
77 if ok {
78 recorded = &grant
79 }
80 r.grants[pubkey] = recorded
81 return recorded, nil
82}
83
84// decides reports whether a key may approve or reject proposals in the
85// repository: its owner, a key in the announcement's maintainers tag, an
86// agent whose active grant holds maintain on it, the relay owner and
87// moderators.
88func (r *collaborationRoles) decides(ctx context.Context, pubkey string) (bool, error) {
89 if pubkey == "" {
90 return false, nil
91 }
92 if decides, ok := r.deciders[pubkey]; ok {
93 return decides, nil
94 }
95 decides := pubkey == r.repo.Owner || contains(r.repo.Maintainers, pubkey)
96 if !decides {
97 role, err := r.role(ctx, pubkey)
98 if err != nil {
99 return false, err
100 }
101 decides = wikiDecidingRole(role)
102 }
103 if !decides {
104 grant, err := r.grant(ctx, pubkey)
105 if err != nil {
106 return false, err
107 }
108 decides = grant != nil && grant.Maintains(r.repo.Owner, r.repo.Identifier, time.Now().Unix())
109 }
110 r.deciders[pubkey] = decides
111 return decides, nil
112}
113
114// proposer reports whether repository events from a key are proposals: the
115// key holds an agent grant with propose on the repository and no human
116// standing on the relay. A paused, revoked or expired grant keeps counting,
117// so an agent's pending events do not surface when its grant ends.
118func (r *collaborationRoles) proposer(ctx context.Context, pubkey string) (bool, error) {
119 if known, ok := r.proposers[pubkey]; ok {
120 return known, nil
121 }
122 grant, err := r.grant(ctx, pubkey)
123 if err != nil {
124 return false, err
125 }
126 proposer := grant != nil && grant.Proposes(r.repo.Owner, r.repo.Identifier)
127 if proposer {
128 role, err := r.role(ctx, pubkey)
129 if err != nil {
130 return false, err
131 }
132 proposer = role == "" || role == "agent"
133 }
134 r.proposers[pubkey] = proposer
135 return proposer, nil
136}
137
138// collaborationProposalKind reports whether a kind can be a proposal: the
139// kinds a propose grant publishes.
140func collaborationProposalKind(kind int) bool {
141 return kind == event.KIND_GIT_ISSUE || kind == event.KIND_GIT_PR || kind == event.KIND_GIT_PATCH || kind == 1111
142}
143
144// collaborationProposals marks the proposals among rows and resolves each
145// one's state from the deciders' reactions to its event id. The result
146// runs parallel to rows; the newest deciding reaction counts and the
147// author's own never does.
148func (t *Tenant) collaborationProposals(ctx context.Context, roles *collaborationRoles, rows []event.Event) ([]collaborationProposal, error) {
149 states := make([]collaborationProposal, len(rows))
150 var ids []string
151 for i, row := range rows {
152 if !collaborationProposalKind(row.Kind) {
153 continue
154 }
155 proposer, err := roles.proposer(ctx, row.PubKey)
156 if err != nil {
157 return nil, err
158 }
159 if !proposer {
160 continue
161 }
162 states[i] = collaborationProposal{Proposal: true, Approval: wikiProposalPending}
163 ids = append(ids, row.ID)
164 }
165 if len(ids) == 0 {
166 return states, nil
167 }
168 page, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})
169 if err != nil {
170 return nil, err
171 }
172 reactions := page.Events
173 sortEventsNewestFirst(reactions)
174 for _, reaction := range reactions {
175 state := wikiProposalState(reaction.Content)
176 if state == "" {
177 continue
178 }
179 decides, err := roles.decides(ctx, reaction.PubKey)
180 if err != nil {
181 return nil, err
182 }
183 if !decides {
184 continue
185 }
186 for _, id := range event.TagValues(reaction, "e") {
187 for i := range rows {
188 s := &states[i]
189 if rows[i].ID != id || !s.Proposal || s.ApprovalEvent != "" || rows[i].PubKey == reaction.PubKey {
190 continue
191 }
192 s.Approval, s.ApprovalEvent, s.ApprovalAt, s.ApprovalBy = state, reaction.ID, reaction.CreatedAt, reaction.PubKey
193 }
194 }
195 }
196 return states, nil
197}
198
199// collaborationVisible resolves the proposal state of rows and keeps the
200// ones the actor may see: every row that is not a proposal or is approved,
201// plus pending and rejected proposals for the deciders and the proposal's
202// author. The returned states run parallel to the kept rows.
203func (t *Tenant) collaborationVisible(ctx context.Context, roles *collaborationRoles, actor string, rows []event.Event) ([]event.Event, []collaborationProposal, error) {
204 states, err := t.collaborationProposals(ctx, roles, rows)
205 if err != nil {
206 return nil, nil, err
207 }
208 decides, err := roles.decides(ctx, actor)
209 if err != nil {
210 return nil, nil, err
211 }
212 kept := make([]event.Event, 0, len(rows))
213 keptStates := make([]collaborationProposal, 0, len(rows))
214 for i, row := range rows {
215 s := states[i]
216 if !s.Proposal || s.Approval == wikiProposalApproved || decides || (actor != "" && row.PubKey == actor) {
217 kept = append(kept, row)
218 keptStates = append(keptStates, s)
219 }
220 }
221 return kept, keptStates, nil
222}
223
224// collaborationDecided drops status events from proposers, so the status
225// of an issue or pull request never takes a pending event into account.
226func (t *Tenant) collaborationDecided(ctx context.Context, roles *collaborationRoles, rows []event.Event) ([]event.Event, error) {
227 kept := make([]event.Event, 0, len(rows))
228 for _, row := range rows {
229 proposer, err := roles.proposer(ctx, row.PubKey)
230 if err != nil {
231 return nil, err
232 }
233 if !proposer {
234 kept = append(kept, row)
235 }
236 }
237 return kept, nil
238}
239
240// collaborationCoordinate finds the repository an event points at through
241// its first a or A tag.
242func collaborationCoordinate(e event.Event) (owner, identifier string, ok bool) {
243 for _, tag := range e.Tags {
244 if len(tag) < 2 || (tag[0] != "a" && tag[0] != "A") {
245 continue
246 }
247 if owner, identifier, ok = repoCoordinate(tag[1]); ok {
248 return owner, identifier, true
249 }
250 }
251 return "", "", false
252}
253
254// collaborationProposalNotices wakes the repository owner's and
255// maintainers' devices in the approvals category when a proposal arrives.
256// It reports false for every other event. A decision wakes nobody.
257func (t *Tenant) collaborationProposalNotices(ctx context.Context, e event.Event) ([]pushNotice, bool) {
258 if !collaborationProposalKind(e.Kind) || t.git == nil {
259 return nil, false
260 }
261 owner, identifier, ok := collaborationCoordinate(e)
262 if !ok {
263 return nil, false
264 }
265 r, err := t.git.BrowseRepository(owner, identifier)
266 if err != nil {
267 return nil, false
268 }
269 roles := t.collaborationRoles(r)
270 proposer, err := roles.proposer(ctx, e.PubKey)
271 if err != nil || !proposer {
272 return nil, false
273 }
274 name := shortKey(e.PubKey)
275 if grant, err := roles.grant(ctx, e.PubKey); err == nil && grant != nil && strings.TrimSpace(grant.Name) != "" {
276 name = strings.TrimSpace(grant.Name)
277 }
278 what, subject := "comment", e.Content
279 switch e.Kind {
280 case event.KIND_GIT_ISSUE:
281 what, subject = "issue", collaborationTitle(e)
282 case event.KIND_GIT_PR:
283 what, subject = "pull request", collaborationTitle(e)
284 case event.KIND_GIT_PATCH:
285 what, subject = "patch", collaborationTitle(e)
286 }
287 notice := pushNotice{category: pushApprovals, body: name + " proposes " + what + ": " + excerpt(subject), url: t.collaborationProposalURL(e, r)}
288 var notices []pushNotice
289 for _, recipient := range uniqueStrings(t.maintainerKeys(ctx, r)) {
290 if recipient == e.PubKey {
291 continue
292 }
293 notice.recipient = recipient
294 notices = append(notices, notice)
295 }
296 t.app.telemetry.Logger().Debug("repository proposal notifications built", "count", len(notices))
297 return notices, true
298}
299
300// collaborationProposalURL is the page a proposal is read on: the issue or
301// pull request page for a root and for a comment under one, and the event
302// page otherwise.
303func (t *Tenant) collaborationProposalURL(e event.Event, r gitrelay.Repository) string {
304 base := strings.TrimRight(t.publicURL, "/")
305 page := func(view, id string) string {
306 return base + "/repo?owner=" + r.Owner + "&repo=" + r.Identifier + "&view=" + view + "&id=" + id
307 }
308 switch e.Kind {
309 case event.KIND_GIT_ISSUE:
310 return page("issue", e.ID)
311 case event.KIND_GIT_PR:
312 return page("pr", e.ID)
313 case 1111:
314 if root := event.Tag(e, "E"); len(root) == 64 {
315 switch event.Tag(e, "K") {
316 case "1621":
317 return page("issue", root)
318 case "1618":
319 return page("pr", root)
320 }
321 }
322 }
323 return base + "/e/" + e.ID
324}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs