Monorepo for Tangled tangled.org
5

Configure Feed

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

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 >= 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 patch := pull.Submissions[roundIdInt].CombinedPatch() 230 var diff types.DiffRenderer 231 diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 232 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 } 250 251 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 252 BaseParams: pages.BaseParamsFromContext(r.Context()), 253 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 254 Pull: pull, 255 Stack: stack, 256 Backlinks: backlinks, 257 BranchDeleteStatus: nil, 258 MergeCheck: types.MergeCheckResponse{}, 259 ResubmitCheck: pages.Unknown, 260 Pipelines: m, 261 Diff: diff, 262 DiffOpts: diffOpts, 263 ActiveRound: roundIdInt, 264 IsInterdiff: interdiff, 265 266 Reactions: reactions, 267 UserReacted: userReactions, 268 269 LabelDefs: defs, 270 VouchRelationships: vouchRelationships, 271 VouchSkips: vouchSkips, 272 }) 273 if err != nil { 274 l.Error("failed to render page", "err", err) 275 } 276} 277 278func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 279 l := s.logger.With("handler", "RepoSinglePull") 280 281 pull, ok := r.Context().Value("pull").(*models.Pull) 282 if !ok { 283 l.Error("failed to get pull") 284 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 285 return 286 } 287 288 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 289} 290 291func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 292 if pull.State == models.PullMerged { 293 return types.MergeCheckResponse{} 294 } 295 296 xrpcc := s.knotClient(f.Knot) 297 298 // combine patches of substack 299 subStack := stack.Below(pull) 300 // collect the portion of the stack that is mergeable 301 mergeable := subStack.Mergeable() 302 // combine each patch 303 patch := mergeable.CombinedPatch() 304 305 resp, err := tangled.RepoMergeCheck( 306 r.Context(), 307 xrpcc, 308 &tangled.RepoMergeCheck_Input{ 309 Did: f.Did, 310 Name: f.Name, 311 Branch: pull.TargetBranch, 312 Patch: patch, 313 }, 314 ) 315 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 316 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 317 return types.MergeCheckResponse{ 318 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 319 } 320 } 321 322 return mergeCheckResponseFrom(resp) 323} 324 325func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 326 conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 327 for i, c := range resp.Conflicts { 328 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 329 } 330 out := types.MergeCheckResponse{ 331 IsConflicted: resp.Is_conflicted, 332 Conflicts: conflicts, 333 } 334 if resp.Message != nil { 335 out.Message = *resp.Message 336 } 337 if resp.Error != nil { 338 out.Error = *resp.Error 339 } 340 return out 341} 342 343func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 344 if pull.State != models.PullMerged { 345 return nil 346 } 347 348 user := s.oauth.GetMultiAccountUser(r) 349 if user == nil { 350 return nil 351 } 352 353 var branch string 354 // check if the branch exists 355 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 356 if pull.IsBranchBased() { 357 branch = pull.PullSource.Branch 358 } else if pull.IsForkBased() { 359 branch = pull.PullSource.Branch 360 repo = pull.PullSource.Repo 361 } else { 362 return nil 363 } 364 365 // deleted fork 366 if repo == nil { 367 return nil 368 } 369 370 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 371 if !s.acl.HasRepoPermission(r.Context(), repo, user.Did, "repo:push") { 372 return nil 373 } 374 375 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 376 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoDid) 377 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 378 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 379 return nil 380 } 381 382 return &models.BranchDeleteStatus{ 383 Repo: repo, 384 Branch: resp.Name, 385 } 386} 387 388func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 389 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 390 return pages.Unknown 391 } 392 393 var sourceRepoDid string 394 if pull.PullSource.RepoDid != nil { 395 sourceRepoDid = string(*pull.PullSource.RepoDid) 396 } else { 397 sourceRepoDid = repo.RepoDid 398 } 399 400 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 401 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid) 402 if err != nil { 403 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 404 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 405 return pages.Unknown 406 } 407 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 408 return pages.Unknown 409 } 410 411 targetBranch := branchResp 412 413 top := stack[0] 414 latestSourceRev := top.LatestSha() 415 416 if latestSourceRev != targetBranch.Hash { 417 return pages.ShouldResubmit 418 } 419 420 return pages.ShouldNotResubmit 421} 422 423func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 424 s.repoPullHelper(w, r, false) 425} 426 427func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 428 s.repoPullHelper(w, r, true) 429} 430 431func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 432 l := s.logger.With("handler", "RepoPullPatchRaw") 433 434 pull, ok := r.Context().Value("pull").(*models.Pull) 435 if !ok { 436 l.Error("failed to get pull") 437 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 438 return 439 } 440 l = l.With("pull_id", pull.PullId) 441 442 roundId := chi.URLParam(r, "round") 443 roundIdInt, err := strconv.Atoi(roundId) 444 if err != nil || roundIdInt >= len(pull.Submissions) { 445 http.Error(w, "bad round id", http.StatusBadRequest) 446 l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 447 return 448 } 449 450 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 451 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 452}