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