Monorepo for Tangled tangled.org
10

Configure Feed

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

1package pulls 2 3import ( 4 "fmt" 5 "net/http" 6 "slices" 7 "strconv" 8 9 "tangled.org/core/api/tangled" 10 "tangled.org/core/appview/db" 11 "tangled.org/core/appview/models" 12 "tangled.org/core/appview/pages" 13 "tangled.org/core/appview/xrpcclient" 14 "tangled.org/core/orm" 15 "tangled.org/core/patchutil" 16 "tangled.org/core/types" 17 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 20 "github.com/go-chi/chi/v5" 21) 22 23// htmx fragment 24func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 25 l := s.logger.With("handler", "PullActions") 26 27 switch r.Method { 28 case http.MethodGet: 29 user := s.oauth.GetMultiAccountUser(r) 30 if user != nil { 31 l = l.With("user", user.Did) 32 } 33 34 f, err := s.repoResolver.Resolve(r) 35 if err != nil { 36 l.Error("failed to get repo and knot", "err", err) 37 return 38 } 39 40 pull, ok := r.Context().Value("pull").(*models.Pull) 41 if !ok { 42 l.Error("failed to get pull") 43 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 44 return 45 } 46 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 47 48 // can be nil if this pull is not stacked 49 stack, _ := r.Context().Value("stack").(models.Stack) 50 51 roundNumberStr := chi.URLParam(r, "round") 52 roundNumber, err := strconv.Atoi(roundNumberStr) 53 if err != nil { 54 roundNumber = pull.LastRoundNumber() 55 } 56 if roundNumber >= len(pull.Submissions) { 57 http.Error(w, "bad round id", http.StatusBadRequest) 58 l.Error("failed to parse round id", "err", err, "round_number", roundNumber) 59 return 60 } 61 62 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 63 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 64 resubmitResult := pages.Unknown 65 if user.Did == pull.OwnerDid { 66 resubmitResult = s.resubmitCheck(r, f, pull, stack) 67 } 68 69 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 70 LoggedInUser: user, 71 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 72 Pull: pull, 73 RoundNumber: roundNumber, 74 MergeCheck: mergeCheckResponse, 75 ResubmitCheck: resubmitResult, 76 BranchDeleteStatus: branchDeleteStatus, 77 Stack: stack, 78 }) 79 return 80 } 81} 82 83func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 84 l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff) 85 86 user := s.oauth.GetMultiAccountUser(r) 87 if user != nil { 88 l = l.With("user", user.Did) 89 } 90 91 f, err := s.repoResolver.Resolve(r) 92 if err != nil { 93 l.Error("failed to get repo and knot", "err", err) 94 return 95 } 96 97 pull, ok := r.Context().Value("pull").(*models.Pull) 98 if !ok { 99 l.Error("failed to get pull") 100 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 101 return 102 } 103 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 104 105 backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 106 if err != nil { 107 l.Error("failed to get pull backlinks", "err", err) 108 s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 109 return 110 } 111 112 roundId := chi.URLParam(r, "round") 113 roundIdInt := pull.LastRoundNumber() 114 if r, err := strconv.Atoi(roundId); err == nil { 115 roundIdInt = r 116 } 117 if roundIdInt >= len(pull.Submissions) { 118 http.Error(w, "bad round id", http.StatusBadRequest) 119 l.Error("failed to parse round id", "err", err, "round_number", roundIdInt) 120 return 121 } 122 123 var diffOpts types.DiffOpts 124 if d := r.URL.Query().Get("diff"); d == "split" { 125 diffOpts.Split = true 126 } 127 128 // can be nil if this pull is not stacked 129 stack, _ := r.Context().Value("stack").(models.Stack) 130 131 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 132 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 133 resubmitResult := pages.Unknown 134 if user != nil && user.Did == pull.OwnerDid { 135 resubmitResult = s.resubmitCheck(r, f, pull, stack) 136 } 137 138 m := make(map[string]models.Pipeline) 139 140 var shas []string 141 for _, s := range pull.Submissions { 142 shas = append(shas, s.SourceRev) 143 } 144 for _, p := range stack { 145 shas = append(shas, p.LatestSha()) 146 } 147 148 ps, err := db.GetPipelineStatuses( 149 s.db, 150 len(shas), 151 orm.FilterEq("p.repo_owner", f.Did), 152 orm.FilterEq("p.repo_name", f.Rkey), 153 orm.FilterEq("p.knot", f.Knot), 154 orm.FilterIn("p.sha", shas), 155 ) 156 if err != nil { 157 l.Error("failed to fetch pipeline statuses", "err", err) 158 // non-fatal 159 } 160 161 for _, p := range ps { 162 m[p.Sha] = p 163 } 164 165 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 166 if err != nil { 167 l.Error("failed to get pull reactions", "err", err) 168 } 169 170 userReactions := map[models.ReactionKind]bool{} 171 if user != nil { 172 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 173 } 174 175 labelDefs, err := db.GetLabelDefinitions( 176 s.db, 177 orm.FilterIn("at_uri", f.Labels), 178 orm.FilterContains("scope", tangled.RepoPullNSID), 179 ) 180 if err != nil { 181 l.Error("failed to fetch labels", "err", err) 182 s.pages.Error503(w) 183 return 184 } 185 186 defs := make(map[string]*models.LabelDefinition) 187 for _, l := range labelDefs { 188 defs[l.AtUri().String()] = &l 189 } 190 191 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 192 vouchSkips := make(map[syntax.DID]bool) 193 if user != nil { 194 participants := pull.Participants() 195 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants) 196 if err != nil { 197 l.Error("failed to fetch vouch relationships", "err", err) 198 } 199 ownerDid := syntax.DID(pull.OwnerDid) 200 skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid) 201 if err != nil { 202 l.Error("failed to check vouch skip", "err", err) 203 } 204 vouchSkips[ownerDid] = skipped 205 } 206 207 patch := pull.Submissions[roundIdInt].CombinedPatch() 208 var diff types.DiffRenderer 209 diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 210 211 if interdiff { 212 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 213 if err != nil { 214 l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt) 215 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 216 return 217 } 218 219 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 220 if err != nil { 221 l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt) 222 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 223 return 224 } 225 226 diff = patchutil.Interdiff(previousPatch, currentPatch) 227 } 228 229 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 230 LoggedInUser: user, 231 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 232 Pull: pull, 233 Stack: stack, 234 Backlinks: backlinks, 235 BranchDeleteStatus: branchDeleteStatus, 236 MergeCheck: mergeCheckResponse, 237 ResubmitCheck: resubmitResult, 238 Pipelines: m, 239 Diff: diff, 240 DiffOpts: diffOpts, 241 ActiveRound: roundIdInt, 242 IsInterdiff: interdiff, 243 244 Reactions: reactionMap, 245 UserReacted: userReactions, 246 247 LabelDefs: defs, 248 VouchRelationships: vouchRelationships, 249 VouchSkips: vouchSkips, 250 }) 251 if err != nil { 252 l.Error("failed to render page", "err", err) 253 } 254} 255 256func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 257 l := s.logger.With("handler", "RepoSinglePull") 258 259 pull, ok := r.Context().Value("pull").(*models.Pull) 260 if !ok { 261 l.Error("failed to get pull") 262 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 263 return 264 } 265 266 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 267} 268 269func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 270 if pull.State == models.PullMerged { 271 return types.MergeCheckResponse{} 272 } 273 274 xrpcc := s.knotClient(f.Knot) 275 276 // combine patches of substack 277 subStack := stack.Below(pull) 278 // collect the portion of the stack that is mergeable 279 mergeable := subStack.Mergeable() 280 // combine each patch 281 patch := mergeable.CombinedPatch() 282 283 resp, err := tangled.RepoMergeCheck( 284 r.Context(), 285 xrpcc, 286 &tangled.RepoMergeCheck_Input{ 287 Did: f.Did, 288 Name: f.Name, 289 Branch: pull.TargetBranch, 290 Patch: patch, 291 }, 292 ) 293 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 294 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 295 return types.MergeCheckResponse{ 296 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 297 } 298 } 299 300 return mergeCheckResponseFrom(resp) 301} 302 303func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 304 conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 305 for i, c := range resp.Conflicts { 306 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 307 } 308 out := types.MergeCheckResponse{ 309 IsConflicted: resp.Is_conflicted, 310 Conflicts: conflicts, 311 } 312 if resp.Message != nil { 313 out.Message = *resp.Message 314 } 315 if resp.Error != nil { 316 out.Error = *resp.Error 317 } 318 return out 319} 320 321func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 322 if pull.State != models.PullMerged { 323 return nil 324 } 325 326 user := s.oauth.GetMultiAccountUser(r) 327 if user == nil { 328 return nil 329 } 330 331 var branch string 332 // check if the branch exists 333 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 334 if pull.IsBranchBased() { 335 branch = pull.PullSource.Branch 336 } else if pull.IsForkBased() { 337 branch = pull.PullSource.Branch 338 repo = pull.PullSource.Repo 339 } else { 340 return nil 341 } 342 343 // deleted fork 344 if repo == nil { 345 return nil 346 } 347 348 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 349 perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier()) 350 if !slices.Contains(perms, "repo:push") { 351 return nil 352 } 353 354 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 355 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoAt().String()) 356 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 357 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 358 return nil 359 } 360 361 return &models.BranchDeleteStatus{ 362 Repo: repo, 363 Branch: resp.Name, 364 } 365} 366 367func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 368 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 369 return pages.Unknown 370 } 371 372 var sourceRepoDid string 373 if pull.PullSource.RepoDid != nil { 374 sourceRepoDid = string(*pull.PullSource.RepoDid) 375 } else { 376 sourceRepoDid = repo.RepoDid 377 } 378 379 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 380 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid) 381 if err != nil { 382 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 383 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 384 return pages.Unknown 385 } 386 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 387 return pages.Unknown 388 } 389 390 targetBranch := branchResp 391 392 top := stack[0] 393 latestSourceRev := top.LatestSha() 394 395 if latestSourceRev != targetBranch.Hash { 396 return pages.ShouldResubmit 397 } 398 399 return pages.ShouldNotResubmit 400} 401 402func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 403 s.repoPullHelper(w, r, false) 404} 405 406func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 407 s.repoPullHelper(w, r, true) 408} 409 410func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 411 l := s.logger.With("handler", "RepoPullPatchRaw") 412 413 pull, ok := r.Context().Value("pull").(*models.Pull) 414 if !ok { 415 l.Error("failed to get pull") 416 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 417 return 418 } 419 l = l.With("pull_id", pull.PullId) 420 421 roundId := chi.URLParam(r, "round") 422 roundIdInt, err := strconv.Atoi(roundId) 423 if err != nil || roundIdInt >= len(pull.Submissions) { 424 http.Error(w, "bad round id", http.StatusBadRequest) 425 l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 426 return 427 } 428 429 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 430 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 431}