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