Monorepo for Tangled tangled.org
1

Configure Feed

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

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