Monorepo for Tangled tangled.org
6

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