Monorepo for Tangled
tangled.org
1package pulls
2
3import (
4 "context"
5 "net/http"
6 "slices"
7
8 "tangled.org/core/api/tangled"
9 "tangled.org/core/appview/db"
10 "tangled.org/core/appview/models"
11 "tangled.org/core/appview/pages"
12 "tangled.org/core/appview/pagination"
13 "tangled.org/core/appview/searchquery"
14 "tangled.org/core/orm"
15
16 "github.com/bluesky-social/indigo/atproto/syntax"
17 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
18)
19
20func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
21 l := s.logger.With("handler", "RepoPulls")
22
23 user := s.oauth.GetMultiAccountUser(r)
24 if user != nil {
25 l = l.With("user", user.Did)
26 }
27
28 params := r.URL.Query()
29 page := pagination.FromContext(r.Context())
30
31 f, err := s.repoResolver.Resolve(r)
32 if err != nil {
33 l.Error("failed to get repo and knot", "err", err)
34 return
35 }
36 l = l.With("repo_at", f.RepoAt().String())
37
38 query := searchquery.Parse(params.Get("q"))
39
40 var state *models.PullState
41 if urlState := params.Get("state"); urlState != "" {
42 switch urlState {
43 case "open":
44 state = ptrPullState(models.PullOpen)
45 case "closed":
46 state = ptrPullState(models.PullClosed)
47 case "merged":
48 state = ptrPullState(models.PullMerged)
49 }
50 query.Set("state", urlState)
51 } else if queryState := query.Get("state"); queryState != nil {
52 switch *queryState {
53 case "open":
54 state = ptrPullState(models.PullOpen)
55 case "closed":
56 state = ptrPullState(models.PullClosed)
57 case "merged":
58 state = ptrPullState(models.PullMerged)
59 }
60 } else if _, hasQ := params["q"]; !hasQ {
61 state = ptrPullState(models.PullOpen)
62 query.Set("state", "open")
63 }
64
65 resolve := func(ctx context.Context, ident string) (string, error) {
66 id, err := s.idResolver.ResolveIdent(ctx, ident)
67 if err != nil {
68 return "", err
69 }
70 return id.DID.String(), nil
71 }
72
73 authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l)
74
75 labels := query.GetAll("label")
76 negatedLabels := query.GetAllNegated("label")
77 labelValues := query.GetDynamicTags()
78 negatedLabelValues := query.GetNegatedDynamicTags()
79
80 // resolve DID-format label values: if a dynamic tag's label
81 // definition has format "did", resolve the handle to a DID
82 if len(labelValues) > 0 || len(negatedLabelValues) > 0 {
83 labelDefs, err := db.GetLabelDefinitions(
84 s.db,
85 orm.FilterIn("at_uri", f.Labels),
86 orm.FilterContains("scope", tangled.RepoPullNSID),
87 )
88 if err == nil {
89 didLabels := make(map[string]bool)
90 for _, def := range labelDefs {
91 if def.ValueType.Format == models.ValueTypeFormatDid {
92 didLabels[def.Name] = true
93 }
94 }
95 labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l)
96 negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l)
97 } else {
98 l.Debug("failed to fetch label definitions for DID resolution", "err", err)
99 }
100 }
101
102 tf := searchquery.ExtractTextFilters(query)
103
104 searchOpts := models.PullSearchOptions{
105 Keywords: tf.Keywords,
106 Phrases: tf.Phrases,
107 RepoDid: f.RepoDid,
108 State: state,
109 AuthorDid: authorDid,
110 Labels: labels,
111 LabelValues: labelValues,
112 NegatedKeywords: tf.NegatedKeywords,
113 NegatedPhrases: tf.NegatedPhrases,
114 NegatedLabels: negatedLabels,
115 NegatedLabelValues: negatedLabelValues,
116 NegatedAuthorDids: negatedAuthorDids,
117 Page: page,
118 }
119
120 var totalPulls int
121 if state == nil {
122 totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed
123 } else {
124 switch *state {
125 case models.PullOpen:
126 totalPulls = f.RepoStats.PullCount.Open
127 case models.PullMerged:
128 totalPulls = f.RepoStats.PullCount.Merged
129 case models.PullClosed:
130 totalPulls = f.RepoStats.PullCount.Closed
131 }
132 }
133
134 repoInfo := s.repoResolver.GetRepoInfo(r, user)
135
136 var pulls []*models.Pull
137
138 if searchOpts.HasSearchFilters() {
139 res, err := s.indexer.Search(r.Context(), searchOpts)
140 if err != nil {
141 l.Error("failed to search for pulls", "err", err)
142 return
143 }
144 totalPulls = int(res.Total)
145 l.Debug("searched pulls with indexer", "count", len(res.Hits))
146
147 // update tab counts to reflect filtered results
148 countOpts := searchOpts
149 countOpts.Page = pagination.Page{Limit: 1}
150 for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} {
151 countOpts.State = &ps
152 countRes, err := s.indexer.Search(r.Context(), countOpts)
153 if err != nil {
154 continue
155 }
156 switch ps {
157 case models.PullOpen:
158 repoInfo.Stats.PullCount.Open = int(countRes.Total)
159 case models.PullMerged:
160 repoInfo.Stats.PullCount.Merged = int(countRes.Total)
161 case models.PullClosed:
162 repoInfo.Stats.PullCount.Closed = int(countRes.Total)
163 }
164 }
165
166 if len(res.Hits) > 0 {
167 pulls, err = db.GetPulls(
168 s.db,
169 orm.FilterIn("id", res.Hits),
170 )
171 if err != nil {
172 l.Error("failed to get pulls", "err", err)
173 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
174 return
175 }
176 }
177 } else {
178 filters := []orm.Filter{
179 orm.FilterEq("repo_did", f.RepoDid),
180 }
181 if state != nil {
182 filters = append(filters, orm.FilterEq("state", *state))
183 }
184 pulls, err = db.GetPullsPaginated(
185 s.db,
186 page,
187 filters...,
188 )
189 if err != nil {
190 l.Error("failed to get pulls", "err", err)
191 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
192 return
193 }
194 }
195
196 for _, p := range pulls {
197 var pullSourceRepo *models.Repo
198 if p.PullSource != nil {
199 if p.PullSource.RepoDid != nil {
200 pullSourceRepo, err = db.GetRepoByDid(s.db, string(*p.PullSource.RepoDid))
201 if err != nil {
202 l.Error("failed to get repo by did", "err", err, "repo_did", p.PullSource.RepoDid.String())
203 continue
204 } else {
205 p.PullSource.Repo = pullSourceRepo
206 }
207 }
208 }
209 }
210
211 var stacks []models.Stack
212 var shas []string
213
214 pullMap := make(map[string]*models.Pull)
215 for _, p := range pulls {
216 shas = append(shas, p.LatestSha())
217 pullMap[p.AtUri().String()] = p
218 }
219
220 // track which PRs have been added to stacks
221 visited := make(map[string]bool)
222
223 // group stacked PRs together using dependent_on relationships
224 for _, p := range pulls {
225 if visited[p.AtUri().String()] {
226 continue
227 }
228
229 root := p
230 for root.DependentOn != nil {
231 if parent, ok := pullMap[root.DependentOn.String()]; ok {
232 root = parent
233 } else {
234 break // parent not in current page
235 }
236 }
237
238 var stack models.Stack
239 current := root
240 for {
241 if visited[current.AtUri().String()] {
242 break
243 }
244 stack = append(stack, current)
245 visited[current.AtUri().String()] = true
246
247 found := false
248 for _, candidate := range pulls {
249 if candidate.DependentOn != nil &&
250 candidate.DependentOn.String() == current.AtUri().String() {
251 current = candidate
252 found = true
253 break
254 }
255 }
256 if !found {
257 break
258 }
259 }
260
261 slices.Reverse(stack)
262 stacks = append(stacks, stack)
263 }
264
265 // commitId -> latest pipeline
266 pipelines := func(ctx context.Context, shas []string) map[string]tangled.CiDefs_Pipeline {
267 xrpcc := &indigoxrpc.Client{Host: f.Spindle}
268 out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", 0, f.RepoDid)
269 if err != nil {
270 l.Error("failed to fetch pipelines", "err", err)
271 }
272
273 m := make(map[string]tangled.CiDefs_Pipeline)
274
275 for _, pipeline := range out.Pipelines {
276 if pipeline == nil {
277 continue
278 }
279 m[pipeline.Commit] = *pipeline
280 }
281 return m
282 }(r.Context(), shas)
283
284 labelDefs, err := db.GetLabelDefinitions(
285 s.db,
286 orm.FilterIn("at_uri", f.Labels),
287 orm.FilterContains("scope", tangled.RepoPullNSID),
288 )
289 if err != nil {
290 l.Error("failed to fetch labels", "err", err)
291 s.pages.Error503(w)
292 return
293 }
294
295 defs := make(map[string]*models.LabelDefinition)
296 for _, l := range labelDefs {
297 defs[l.AtUri().String()] = &l
298 }
299
300 filterState := ""
301 if state != nil {
302 filterState = state.String()
303 }
304
305 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
306 if user != nil {
307 dids := make([]syntax.DID, len(pulls))
308 for i, p := range pulls {
309 dids[i] = syntax.DID(p.OwnerDid)
310 }
311 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), dids)
312 if err != nil {
313 l.Error("failed to fetch vouch relationships", "err", err)
314 }
315 }
316
317 err = s.pages.RepoPulls(w, pages.RepoPullsParams{
318 BaseParams: pages.BaseParamsFromContext(r.Context()),
319 RepoInfo: repoInfo,
320 Pulls: pulls,
321 LabelDefs: defs,
322 FilterState: filterState,
323 FilterQuery: query.String(),
324 Stacks: stacks,
325 Pipelines: pipelines,
326 Page: page,
327 PullCount: totalPulls,
328 VouchRelationships: vouchRelationships,
329 })
330 if err != nil {
331 l.Error("failed to render page", "err", err)
332 }
333}