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