Monorepo for Tangled
tangled.org
1package pulls
2
3import (
4 "context"
5 "iter"
6 "net/url"
7 "slices"
8 "time"
9
10 "tangled.org/core/api/tangled"
11 "tangled.org/core/appview/db"
12 "tangled.org/core/appview/models"
13 "tangled.org/core/orm"
14 "tangled.org/core/tid"
15
16 comatproto "github.com/bluesky-social/indigo/api/atproto"
17 "github.com/bluesky-social/indigo/atproto/atclient"
18 "github.com/bluesky-social/indigo/atproto/syntax"
19 lexutil "github.com/bluesky-social/indigo/lex/util"
20)
21
22func (s *Pulls) pullLabelDefs(repo *models.Repo) (map[string]*models.LabelDefinition, error) {
23 defs, err := db.GetLabelDefinitions(
24 s.db,
25 orm.FilterIn("at_uri", repo.Labels),
26 orm.FilterContains("scope", tangled.RepoPullNSID),
27 )
28 if err != nil {
29 return nil, err
30 }
31
32 out := make(map[string]*models.LabelDefinition, len(defs))
33 for i := range defs {
34 d := defs[i]
35 if !slices.Contains(d.Scope, tangled.RepoPullNSID) {
36 continue
37 }
38 out[d.AtUri().String()] = &d
39 }
40 return out, nil
41}
42
43func formLabelEntries(form url.Values, defs map[string]*models.LabelDefinition) iter.Seq2[string, string] {
44 return func(yield func(string, string) bool) {
45 for key := range defs {
46 for _, v := range form[key] {
47 if v == "" {
48 continue
49 }
50 if !yield(key, v) {
51 return
52 }
53 }
54 }
55 }
56}
57
58func labelStateFromForm(form url.Values, defs map[string]*models.LabelDefinition) models.LabelState {
59 state := models.NewLabelState()
60 actx := &models.LabelApplicationCtx{Defs: defs}
61 for key, val := range formLabelEntries(form, defs) {
62 _ = actx.ApplyLabelOp(state, models.LabelOp{
63 Operation: models.LabelOperationAdd,
64 OperandKey: key,
65 OperandValue: val,
66 })
67 }
68 return state
69}
70
71func buildCreationLabelOps(
72 userDid syntax.DID,
73 subject syntax.ATURI,
74 rkey string,
75 form url.Values,
76 defs map[string]*models.LabelDefinition,
77 performedAt time.Time,
78) []models.LabelOp {
79 var ops []models.LabelOp
80 for key, val := range formLabelEntries(form, defs) {
81 ops = append(ops, models.LabelOp{
82 Did: userDid.String(),
83 Rkey: rkey,
84 Subject: subject,
85 Operation: models.LabelOperationAdd,
86 OperandKey: key,
87 OperandValue: val,
88 PerformedAt: performedAt,
89 })
90 }
91 return ops
92}
93
94func (s *Pulls) applyCreationLabels(
95 ctx context.Context,
96 client *atclient.APIClient,
97 userDid syntax.DID,
98 pulls []*models.Pull,
99 form url.Values,
100 repo *models.Repo,
101) {
102 l := s.logger.With("handler", "applyCreationLabels", "user", userDid)
103
104 defs, err := s.pullLabelDefs(repo)
105 if err != nil {
106 l.Warn("failed to fetch label defs", "err", err)
107 return
108 }
109 if len(defs) == 0 {
110 return
111 }
112
113 perCidForms := parseStackLabelForms(form)
114
115 applyAll := form.Get("applyLabelsToAll") == "on"
116 var firstStackForm url.Values
117 if applyAll && len(pulls) > 0 && len(pulls[0].Submissions) > 0 {
118 if firstCid := pulls[0].Submissions[0].ChangeId(); firstCid != "" {
119 if f, ok := perCidForms[firstCid]; ok {
120 firstStackForm = f
121 }
122 }
123 }
124
125 performedAt := time.Now()
126 for _, pull := range pulls {
127 labelForm := form
128 if firstStackForm != nil {
129 labelForm = firstStackForm
130 } else if len(perCidForms) > 0 && len(pull.Submissions) > 0 {
131 if cid := pull.Submissions[0].ChangeId(); cid != "" {
132 if perForm, ok := perCidForms[cid]; ok {
133 labelForm = perForm
134 }
135 }
136 }
137 rkey := tid.TID()
138 raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt)
139
140 valid := make([]models.LabelOp, 0, len(raw))
141 for _, op := range raw {
142 def := defs[op.OperandKey]
143
144 // validate permissions: only collaborators can apply labels currently
145 //
146 // TODO: introduce a repo:triage permission
147 ok, err := s.acl.HasRepoPermissionErr(ctx, repo, op.Did, "repo:push")
148 if err != nil {
149 l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey)
150 continue
151 }
152 if !ok {
153 l.Warn("forbidden label op", "subject", op.Subject, "key", op.OperandKey)
154 continue
155 }
156
157 // resolve Handle to DID
158 if def.ValueType.IsString() && def.ValueType.IsDidFormat() {
159 val := syntax.AtIdentifier(op.OperandValue)
160 if val.IsHandle() {
161 ident, err := s.idResolver.Directory().Lookup(ctx, val)
162 if err != nil {
163 l.Warn("failed to resolve handle", "err", err, "subject", op.Subject, "key", op.OperandKey)
164 }
165 op.OperandValue = ident.DID.String()
166 }
167 }
168
169 if err := def.ValidateOperandValue(&op); err != nil {
170 l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey)
171 continue
172 }
173 valid = append(valid, op)
174 }
175 if len(valid) == 0 {
176 continue
177 }
178
179 record := models.LabelOpsAsRecord(valid)
180 if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
181 Collection: tangled.LabelOpNSID,
182 Repo: userDid.String(),
183 Rkey: rkey,
184 Record: &lexutil.LexiconTypeDecoder{Val: &record},
185 }); err != nil {
186 l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri())
187 continue
188 }
189
190 if err := s.indexLabelOps(ctx, valid); err != nil {
191 l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri())
192 if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{
193 Collection: tangled.LabelOpNSID,
194 Repo: userDid.String(),
195 Rkey: rkey,
196 }); err != nil {
197 l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri())
198 }
199 continue
200 }
201
202 s.notifier.NewPullLabelOp(ctx, userDid, pull, valid)
203 }
204}
205
206func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error {
207 tx, err := s.db.BeginTx(ctx, nil)
208 if err != nil {
209 return err
210 }
211 defer tx.Rollback()
212 for _, op := range ops {
213 if _, err := db.AddLabelOp(tx, &op); err != nil {
214 return err
215 }
216 }
217 return tx.Commit()
218}