clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / internal / daemon / collaboration_approvals.go
collaboration_approvals.go 10.9K
1package daemon23// Repository proposals. An issue, pull request, patch or comment whose4// author holds an agent grant with propose on that repository is a5// proposal: it stays pending until the repository owner, one of its6// maintainers, the relay owner or a moderator reacts to the event id with7// "+", which approves it, or "-", which rejects it. The newest deciding8// reaction counts; reactions from members and from the author do not. An9// updated event is a new id and starts pending again. A pending or10// rejected proposal is visible only to those deciders and its author; for11// everyone else it does not exist. The state is computed here and carried12// on every browse result that lists repository conversations.1314import (15 "context"16 "strings"17 "time"1819 "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)2425// collaborationProposal is the proposal state carried on a listed or shown26// item. Proposal marks an event from an agent whose grant says propose on27// the repository. Approval is pending, approved or rejected; the other28// fields name the deciding reaction, its time and its author once one29// 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}3738// collaborationRoles caches the role and grant lookups one browse call39// makes for a repository, since a thread's events and their reactions40// repeat the same few keys.41type collaborationRoles struct {42 t *Tenant43 repo gitrelay.Repository44 roles map[string]string45 grants map[string]*community.AgentGrant46 proposers map[string]bool47 deciders map[string]bool48}4950func (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}5354func (r *collaborationRoles) role(ctx context.Context, pubkey string) (string, error) {55 if role, ok := r.roles[pubkey]; ok {56 return role, nil57 }58 role, err := r.t.community.Role(ctx, pubkey)59 if err != nil {60 return "", err61 }62 r.roles[pubkey] = role63 return role, nil64}6566// grant returns the agent grant recorded for a key, or nil when there is67// none.68func (r *collaborationRoles) grant(ctx context.Context, pubkey string) (*community.AgentGrant, error) {69 if grant, ok := r.grants[pubkey]; ok {70 return grant, nil71 }72 grant, ok, err := r.t.community.AgentGrant(ctx, pubkey)73 if err != nil {74 return nil, err75 }76 var recorded *community.AgentGrant77 if ok {78 recorded = &grant79 }80 r.grants[pubkey] = recorded81 return recorded, nil82}8384// decides reports whether a key may approve or reject proposals in the85// repository: its owner, a key in the announcement's maintainers tag, an86// agent whose active grant holds maintain on it, the relay owner and87// moderators.88func (r *collaborationRoles) decides(ctx context.Context, pubkey string) (bool, error) {89 if pubkey == "" {90 return false, nil91 }92 if decides, ok := r.deciders[pubkey]; ok {93 return decides, nil94 }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, err100 }101 decides = wikiDecidingRole(role)102 }103 if !decides {104 grant, err := r.grant(ctx, pubkey)105 if err != nil {106 return false, err107 }108 decides = grant != nil && grant.Maintains(r.repo.Owner, r.repo.Identifier, time.Now().Unix())109 }110 r.deciders[pubkey] = decides111 return decides, nil112}113114// proposer reports whether repository events from a key are proposals: the115// key holds an agent grant with propose on the repository and no human116// 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, nil121 }122 grant, err := r.grant(ctx, pubkey)123 if err != nil {124 return false, err125 }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, err131 }132 proposer = role == "" || role == "agent"133 }134 r.proposers[pubkey] = proposer135 return proposer, nil136}137138// collaborationProposalKind reports whether a kind can be a proposal: the139// 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 == 1111142}143144// collaborationProposals marks the proposals among rows and resolves each145// one's state from the deciders' reactions to its event id. The result146// runs parallel to rows; the newest deciding reaction counts and the147// 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 []string151 for i, row := range rows {152 if !collaborationProposalKind(row.Kind) {153 continue154 }155 proposer, err := roles.proposer(ctx, row.PubKey)156 if err != nil {157 return nil, err158 }159 if !proposer {160 continue161 }162 states[i] = collaborationProposal{Proposal: true, Approval: wikiProposalPending}163 ids = append(ids, row.ID)164 }165 if len(ids) == 0 {166 return states, nil167 }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, err171 }172 reactions := page.Events173 sortEventsNewestFirst(reactions)174 for _, reaction := range reactions {175 state := wikiProposalState(reaction.Content)176 if state == "" {177 continue178 }179 decides, err := roles.decides(ctx, reaction.PubKey)180 if err != nil {181 return nil, err182 }183 if !decides {184 continue185 }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 continue191 }192 s.Approval, s.ApprovalEvent, s.ApprovalAt, s.ApprovalBy = state, reaction.ID, reaction.CreatedAt, reaction.PubKey193 }194 }195 }196 return states, nil197}198199// collaborationVisible resolves the proposal state of rows and keeps the200// 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's202// 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, err207 }208 decides, err := roles.decides(ctx, actor)209 if err != nil {210 return nil, nil, err211 }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, nil222}223224// collaborationDecided drops status events from proposers, so the status225// 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, err232 }233 if !proposer {234 kept = append(kept, row)235 }236 }237 return kept, nil238}239240// collaborationCoordinate finds the repository an event points at through241// 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 continue246 }247 if owner, identifier, ok = repoCoordinate(tag[1]); ok {248 return owner, identifier, true249 }250 }251 return "", "", false252}253254// collaborationProposalNotices wakes the repository owner's and255// 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, false260 }261 owner, identifier, ok := collaborationCoordinate(e)262 if !ok {263 return nil, false264 }265 r, err := t.git.BrowseRepository(owner, identifier)266 if err != nil {267 return nil, false268 }269 roles := t.collaborationRoles(r)270 proposer, err := roles.proposer(ctx, e.PubKey)271 if err != nil || !proposer {272 return nil, false273 }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.Content279 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(ctx, e, r)}288 var notices []pushNotice289 for _, recipient := range uniqueStrings(t.maintainerKeys(ctx, r)) {290 if recipient == e.PubKey {291 continue292 }293 notice.recipient = recipient294 notices = append(notices, notice)295 }296 t.app.telemetry.Logger().Debug("repository proposal notifications built", "count", len(notices))297 return notices, true298}299300// collaborationProposalURL is the page a proposal is read on: the issue or301// pull request page for a root and for a comment under one, and the event302// page otherwise.303func (t *Tenant) collaborationProposalURL(ctx context.Context, e event.Event, r gitrelay.Repository) string {304 base := strings.TrimRight(t.publicURL, "/")305 page := func(section, id string) string {306 return t.repoItemURL(ctx, r.Owner, r.Identifier, section, id)307 }308 switch e.Kind {309 case event.KIND_GIT_ISSUE:310 return page("issues", e.ID)311 case event.KIND_GIT_PR:312 return page("prs", 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("issues", root)318 case "1618":319 return page("prs", root)320 }321 }322 }323 return base + "/e/" + e.ID324}