Monorepo for Tangled tangled.org
4

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 if user != nil { 106 repoDid := f.RepoDid 107 userDid := user.Did 108 pullId := pull.PullId 109 go func() { 110 if err := db.MarkNotificationsReadForPull(s.db, userDid, repoDid, pullId); err != nil { 111 l.Error("failed to mark pull notifications as read", "err", err) 112 } 113 }() 114 } 115 116 backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 117 if err != nil { 118 l.Error("failed to get pull backlinks", "err", err) 119 s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 120 return 121 } 122 123 roundId := chi.URLParam(r, "round") 124 roundIdInt := pull.LastRoundNumber() 125 if r, err := strconv.Atoi(roundId); err == nil { 126 roundIdInt = r 127 } 128 if roundIdInt >= len(pull.Submissions) { 129 http.Error(w, "bad round id", http.StatusBadRequest) 130 l.Error("failed to parse round id", "err", err, "round_number", roundIdInt) 131 return 132 } 133 134 var diffOpts types.DiffOpts 135 if d := r.URL.Query().Get("diff"); d == "split" { 136 diffOpts.Split = true 137 } 138 139 // can be nil if this pull is not stacked 140 stack, _ := r.Context().Value("stack").(models.Stack) 141 142 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 143 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 144 resubmitResult := pages.Unknown 145 if user != nil && user.Did == pull.OwnerDid { 146 resubmitResult = s.resubmitCheck(r, f, pull, stack) 147 } 148 149 m := make(map[string]models.Pipeline) 150 151 var shas []string 152 for _, s := range pull.Submissions { 153 shas = append(shas, s.SourceRev) 154 } 155 for _, p := range stack { 156 shas = append(shas, p.LatestSha()) 157 } 158 159 ps, err := db.GetPipelineStatuses( 160 s.db, 161 len(shas), 162 orm.FilterEq("p.repo_did", f.RepoDid), 163 orm.FilterIn("p.sha", shas), 164 ) 165 if err != nil { 166 l.Error("failed to fetch pipeline statuses", "err", err) 167 // non-fatal 168 } 169 170 for _, p := range ps { 171 m[p.Sha] = p 172 } 173 174 entities := []syntax.ATURI{pull.AtUri()} 175 for _, s := range pull.Submissions { 176 for _, c := range s.Comments { 177 entities = append(entities, c.FeedCommentAtUri()) 178 } 179 } 180 reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20) 181 if err != nil { 182 l.Error("failed to get pull reactions", "err", err) 183 } 184 185 var userReactions map[syntax.ATURI]map[models.ReactionKind]bool 186 if user != nil { 187 userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Did)) 188 if err != nil { 189 s.logger.Error("failed to get user reactions", "err", err) 190 } 191 } 192 193 labelDefs, err := db.GetLabelDefinitions( 194 s.db, 195 orm.FilterIn("at_uri", f.Labels), 196 orm.FilterContains("scope", tangled.RepoPullNSID), 197 ) 198 if err != nil { 199 l.Error("failed to fetch labels", "err", err) 200 s.pages.Error503(w) 201 return 202 } 203 204 defs := make(map[string]*models.LabelDefinition) 205 for _, l := range labelDefs { 206 defs[l.AtUri().String()] = &l 207 } 208 209 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 210 vouchSkips := make(map[syntax.DID]bool) 211 if user != nil { 212 participants := pull.Participants() 213 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants) 214 if err != nil { 215 l.Error("failed to fetch vouch relationships", "err", err) 216 } 217 ownerDid := syntax.DID(pull.OwnerDid) 218 skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid) 219 if err != nil { 220 l.Error("failed to check vouch skip", "err", err) 221 } 222 vouchSkips[ownerDid] = skipped 223 } 224 225 patch := pull.Submissions[roundIdInt].CombinedPatch() 226 var diff types.DiffRenderer 227 diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 228 229 if interdiff { 230 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 231 if err != nil { 232 l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt) 233 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 234 return 235 } 236 237 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 238 if err != nil { 239 l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt) 240 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 241 return 242 } 243 244 diff = patchutil.Interdiff(previousPatch, currentPatch) 245 } 246 247 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 248 LoggedInUser: user, 249 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 250 Pull: pull, 251 Stack: stack, 252 Backlinks: backlinks, 253 BranchDeleteStatus: branchDeleteStatus, 254 MergeCheck: mergeCheckResponse, 255 ResubmitCheck: resubmitResult, 256 Pipelines: m, 257 Diff: diff, 258 DiffOpts: diffOpts, 259 ActiveRound: roundIdInt, 260 IsInterdiff: interdiff, 261 262 Reactions: reactions, 263 UserReacted: userReactions, 264 265 LabelDefs: defs, 266 VouchRelationships: vouchRelationships, 267 VouchSkips: vouchSkips, 268 }) 269 if err != nil { 270 l.Error("failed to render page", "err", err) 271 } 272} 273 274func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 275 l := s.logger.With("handler", "RepoSinglePull") 276 277 pull, ok := r.Context().Value("pull").(*models.Pull) 278 if !ok { 279 l.Error("failed to get pull") 280 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 281 return 282 } 283 284 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 285} 286 287func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 288 if pull.State == models.PullMerged { 289 return types.MergeCheckResponse{} 290 } 291 292 xrpcc := s.knotClient(f.Knot) 293 294 // combine patches of substack 295 subStack := stack.Below(pull) 296 // collect the portion of the stack that is mergeable 297 mergeable := subStack.Mergeable() 298 // combine each patch 299 patch := mergeable.CombinedPatch() 300 301 resp, err := tangled.RepoMergeCheck( 302 r.Context(), 303 xrpcc, 304 &tangled.RepoMergeCheck_Input{ 305 Did: f.Did, 306 Name: f.Name, 307 Branch: pull.TargetBranch, 308 Patch: patch, 309 }, 310 ) 311 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 312 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 313 return types.MergeCheckResponse{ 314 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 315 } 316 } 317 318 return mergeCheckResponseFrom(resp) 319} 320 321func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 322 conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 323 for i, c := range resp.Conflicts { 324 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 325 } 326 out := types.MergeCheckResponse{ 327 IsConflicted: resp.Is_conflicted, 328 Conflicts: conflicts, 329 } 330 if resp.Message != nil { 331 out.Message = *resp.Message 332 } 333 if resp.Error != nil { 334 out.Error = *resp.Error 335 } 336 return out 337} 338 339func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 340 if pull.State != models.PullMerged { 341 return nil 342 } 343 344 user := s.oauth.GetMultiAccountUser(r) 345 if user == nil { 346 return nil 347 } 348 349 var branch string 350 // check if the branch exists 351 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 352 if pull.IsBranchBased() { 353 branch = pull.PullSource.Branch 354 } else if pull.IsForkBased() { 355 branch = pull.PullSource.Branch 356 repo = pull.PullSource.Repo 357 } else { 358 return nil 359 } 360 361 // deleted fork 362 if repo == nil { 363 return nil 364 } 365 366 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 367 perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier()) 368 if !slices.Contains(perms, "repo:push") { 369 return nil 370 } 371 372 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 373 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid) 374 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 375 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 376 return nil 377 } 378 379 return &models.BranchDeleteStatus{ 380 Repo: repo, 381 Branch: resp.Name, 382 } 383} 384 385func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 386 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 387 return pages.Unknown 388 } 389 390 var sourceRepoDid string 391 if pull.PullSource.RepoDid != nil { 392 sourceRepoDid = string(*pull.PullSource.RepoDid) 393 } else { 394 sourceRepoDid = repo.RepoDid 395 } 396 397 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 398 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid) 399 if err != nil { 400 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 401 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 402 return pages.Unknown 403 } 404 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 405 return pages.Unknown 406 } 407 408 targetBranch := branchResp 409 410 top := stack[0] 411 latestSourceRev := top.LatestSha() 412 413 if latestSourceRev != targetBranch.Hash { 414 return pages.ShouldResubmit 415 } 416 417 return pages.ShouldNotResubmit 418} 419 420func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 421 s.repoPullHelper(w, r, false) 422} 423 424func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 425 s.repoPullHelper(w, r, true) 426} 427 428func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 429 l := s.logger.With("handler", "RepoPullPatchRaw") 430 431 pull, ok := r.Context().Value("pull").(*models.Pull) 432 if !ok { 433 l.Error("failed to get pull") 434 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 435 return 436 } 437 l = l.With("pull_id", pull.PullId) 438 439 roundId := chi.URLParam(r, "round") 440 roundIdInt, err := strconv.Atoi(roundId) 441 if err != nil || roundIdInt >= len(pull.Submissions) { 442 http.Error(w, "bad round id", http.StatusBadRequest) 443 l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 444 return 445 } 446 447 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 448 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 449}