Monorepo for Tangled
0

Configure Feed

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

tangled-core / appview / pulls / list.go
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}