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