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