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