This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / pulls / list.go
9.0 kB 342 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) 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}