This repository has no description
0

Configure Feed

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

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