Monorepo for Tangled tangled.org
856
fork

Configure Feed

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

appview: handlers, state, and templates for more record types w/ repoDID #279

open opened by oyster.cafe targeting master from lt/repo-rename-by-rkey

Lewis: May this revision serve well! lewis@tangled.org

appview,knotserver: validate git repo ownership according to knot

Lewis: May this revision serve well! lewis@tangled.org

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mjm6w2jhaa22
+3074 -41
Interdiff #2 #3
appview/issues/issues.go

This file has not been changed.

appview/labels/labels.go

This file has not been changed.

appview/middleware/middleware.go

This file has not been changed.

appview/models/repo.go

This file has not been changed.

appview/pages/repoinfo/repoinfo.go

This patch was likely rebased, as context lines do not match.

appview/pages/templates/goodfirstissues/index.html

This file has not been changed.

appview/pages/templates/knots/dashboard.html

This file has not been changed.

appview/pages/templates/layouts/repobase.html

This file has not been changed.

appview/pages/templates/notifications/fragments/item.html

This file has not been changed.

appview/pages/templates/repo/empty.html

This file has not been changed.

appview/pages/templates/repo/fragments/cloneDropdown.html

This file has not been changed.

appview/pages/templates/repo/index.html

This file has not been changed.

appview/pages/templates/repo/pulls/fragments/pullActions.html

This file has not been changed.

appview/pages/templates/repo/pulls/fragments/pullHeader.html

This file has not been changed.

appview/pages/templates/repo/pulls/pull.html

This file has not been changed.

appview/pages/templates/repo/settings/hooks.html

This file has not been changed.

appview/pages/templates/repo/settings/sites.html

This file has not been changed.

appview/pages/templates/spindles/dashboard.html

This file has not been changed.

appview/pages/templates/timeline/fragments/preview.html

This patch was likely rebased, as context lines do not match.

+2 -2
appview/pages/templates/timeline/fragments/timeline.html
··· 67 67 <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 68 68 {{ template "user/fragments/picHandleLink" $starrerHandle }} 69 69 starred 70 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Rkey }}" class="no-underline hover:underline"> 70 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 71 71 {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 72 72 </a> 73 73 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 74 74 {{ else }} 75 75 starred 76 76 {{ end }} 77 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 77 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Rkey }}" class="no-underline hover:underline"> 78 78 {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 79 79 </a> 80 80 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
appview/pages/templates/user/fragments/repoCard.html

This file has not been changed.

appview/pages/templates/user/overview.html

This file has not been changed.

appview/pipelines/pipelines.go

This file has not been changed.

+3066 -35
appview/pulls/pulls.go
··· 3 3 import ( 4 4 "bytes" 5 5 "compress/gzip" 6 + "context" 7 + "database/sql" 8 + "encoding/json" 9 + "errors" 6 10 "fmt" 7 11 "io" 12 + "iter" 8 13 "log/slog" 14 + "net/http" 15 + "net/url" 16 + "slices" 17 + "sort" 18 + "strconv" 19 + "strings" 20 + "time" 9 21 22 + "tangled.org/core/api/tangled" 10 23 "tangled.org/core/appview/config" 11 24 "tangled.org/core/appview/db" 12 25 pulls_indexer "tangled.org/core/appview/indexer/pulls" ··· 15 28 "tangled.org/core/appview/notify" 16 29 "tangled.org/core/appview/oauth" 17 30 "tangled.org/core/appview/pages" 31 + "tangled.org/core/appview/pages/markup" 32 + "tangled.org/core/appview/pages/repoinfo" 33 + "tangled.org/core/appview/pagination" 18 34 "tangled.org/core/appview/reporesolver" 35 + "tangled.org/core/appview/searchquery" 19 36 "tangled.org/core/appview/validator" 37 + "tangled.org/core/appview/xrpcclient" 20 38 "tangled.org/core/idresolver" 21 39 "tangled.org/core/ogre" 40 + "tangled.org/core/orm" 41 + "tangled.org/core/patchutil" 22 42 "tangled.org/core/rbac" 23 - 43 + "tangled.org/core/tid" 44 + "tangled.org/core/types" 45 + "tangled.org/core/xrpc" 46 + 47 + comatproto "github.com/bluesky-social/indigo/api/atproto" 48 + "github.com/bluesky-social/indigo/atproto/atclient" 49 + "github.com/bluesky-social/indigo/atproto/syntax" 50 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 51 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 52 + "github.com/go-chi/chi/v5" 25 53 ) 26 54 27 55 const ApplicationGzip = "application/gzip" ··· 81 109 return &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host)} 82 110 } 83 111 112 + // htmx fragment 113 + func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 114 + l := s.logger.With("handler", "PullActions") 115 + 116 + switch r.Method { 117 + case http.MethodGet: 118 + user := s.oauth.GetMultiAccountUser(r) 119 + if user != nil { 120 + l = l.With("user", user.Did) 121 + } 122 + 123 + f, err := s.repoResolver.Resolve(r) 124 + if err != nil { 125 + l.Error("failed to get repo and knot", "err", err) 126 + return 127 + } 128 + 129 + pull, ok := r.Context().Value("pull").(*models.Pull) 130 + if !ok { 131 + l.Error("failed to get pull") 132 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 133 + return 134 + } 135 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 136 + 137 + // can be nil if this pull is not stacked 138 + stack, _ := r.Context().Value("stack").(models.Stack) 139 + 140 + roundNumberStr := chi.URLParam(r, "round") 141 + roundNumber, err := strconv.Atoi(roundNumberStr) 142 + if err != nil { 143 + roundNumber = pull.LastRoundNumber() 144 + } 145 + if roundNumber >= len(pull.Submissions) { 146 + http.Error(w, "bad round id", http.StatusBadRequest) 147 + l.Error("failed to parse round id", "err", err, "round_number", roundNumber) 148 + return 149 + } 150 + 151 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 152 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 153 + resubmitResult := pages.Unknown 154 + if user.Did == pull.OwnerDid { 155 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 156 + } 157 + 158 + s.pages.PullActionsFragment(w, pages.PullActionsParams{ 159 + LoggedInUser: user, 160 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 161 + Pull: pull, 162 + RoundNumber: roundNumber, 163 + MergeCheck: mergeCheckResponse, 164 + ResubmitCheck: resubmitResult, 165 + BranchDeleteStatus: branchDeleteStatus, 166 + Stack: stack, 167 + }) 168 + return 169 + } 170 + } 171 + 172 + func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 173 + l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff) 174 + 175 + user := s.oauth.GetMultiAccountUser(r) 176 + if user != nil { 177 + l = l.With("user", user.Did) 178 + } 179 + 180 + f, err := s.repoResolver.Resolve(r) 181 + if err != nil { 182 + l.Error("failed to get repo and knot", "err", err) 183 + return 184 + } 185 + 186 + pull, ok := r.Context().Value("pull").(*models.Pull) 187 + if !ok { 188 + l.Error("failed to get pull") 189 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 190 + return 191 + } 192 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 193 + 194 + backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 195 + if err != nil { 196 + l.Error("failed to get pull backlinks", "err", err) 197 + s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 198 + return 199 + } 200 + 201 + roundId := chi.URLParam(r, "round") 202 + roundIdInt := pull.LastRoundNumber() 203 + if r, err := strconv.Atoi(roundId); err == nil { 204 + roundIdInt = r 205 + } 206 + if roundIdInt >= len(pull.Submissions) { 207 + http.Error(w, "bad round id", http.StatusBadRequest) 208 + l.Error("failed to parse round id", "err", err, "round_number", roundIdInt) 209 + return 210 + } 211 + 212 + var diffOpts types.DiffOpts 213 + if d := r.URL.Query().Get("diff"); d == "split" { 214 + diffOpts.Split = true 215 + } 216 + 217 + // can be nil if this pull is not stacked 218 + stack, _ := r.Context().Value("stack").(models.Stack) 219 + 220 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 221 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 222 + resubmitResult := pages.Unknown 223 + if user != nil && user.Did == pull.OwnerDid { 224 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 225 + } 226 + 227 + m := make(map[string]models.Pipeline) 228 + 229 + var shas []string 230 + for _, s := range pull.Submissions { 231 + shas = append(shas, s.SourceRev) 232 + } 233 + for _, p := range stack { 234 + shas = append(shas, p.LatestSha()) 235 + } 236 + 237 + ps, err := db.GetPipelineStatuses( 238 + s.db, 239 + len(shas), 240 + orm.FilterEq("p.repo_owner", f.Did), 241 + orm.FilterEq("p.repo_name", f.Rkey), 242 + orm.FilterEq("p.knot", f.Knot), 243 + orm.FilterIn("p.sha", shas), 244 + ) 245 + if err != nil { 246 + l.Error("failed to fetch pipeline statuses", "err", err) 247 + // non-fatal 248 + } 249 + 250 + for _, p := range ps { 251 + m[p.Sha] = p 252 + } 253 + 254 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 255 + if err != nil { 256 + l.Error("failed to get pull reactions", "err", err) 257 + } 258 + 259 + userReactions := map[models.ReactionKind]bool{} 260 + if user != nil { 261 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 262 + } 263 + 264 + labelDefs, err := db.GetLabelDefinitions( 265 + s.db, 266 + orm.FilterIn("at_uri", f.Labels), 267 + orm.FilterContains("scope", tangled.RepoPullNSID), 268 + ) 269 + if err != nil { 270 + l.Error("failed to fetch labels", "err", err) 271 + s.pages.Error503(w) 272 + return 273 + } 274 + 275 + defs := make(map[string]*models.LabelDefinition) 276 + for _, l := range labelDefs { 277 + defs[l.AtUri().String()] = &l 278 + } 279 + 280 + vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 281 + if user != nil { 282 + participants := pull.Participants() 283 + vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants) 284 + if err != nil { 285 + l.Error("failed to fetch vouch relationships", "err", err) 286 + } 287 + } 288 + 289 + patch := pull.Submissions[roundIdInt].CombinedPatch() 290 + var diff types.DiffRenderer 291 + diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 292 + 293 + if interdiff { 294 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 295 + if err != nil { 296 + l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt) 297 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 298 + return 299 + } 300 + 301 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 302 + if err != nil { 303 + l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt) 304 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 305 + return 306 + } 307 + 308 + diff = patchutil.Interdiff(previousPatch, currentPatch) 309 + } 310 + 311 + err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 312 + LoggedInUser: user, 313 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 314 + Pull: pull, 315 + Stack: stack, 316 + Backlinks: backlinks, 317 + BranchDeleteStatus: branchDeleteStatus, 318 + MergeCheck: mergeCheckResponse, 319 + ResubmitCheck: resubmitResult, 320 + Pipelines: m, 321 + Diff: diff, 322 + DiffOpts: diffOpts, 323 + ActiveRound: roundIdInt, 324 + IsInterdiff: interdiff, 325 + 326 + Reactions: reactionMap, 327 + UserReacted: userReactions, 328 + 329 + LabelDefs: defs, 330 + VouchRelationships: vouchRelationships, 331 + }) 332 + if err != nil { 333 + l.Error("failed to render page", "err", err) 334 + } 335 + } 336 + 337 + func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 338 + l := s.logger.With("handler", "RepoSinglePull") 339 + 340 + pull, ok := r.Context().Value("pull").(*models.Pull) 341 + if !ok { 342 + l.Error("failed to get pull") 343 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 344 + return 345 + } 346 + 347 + http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 348 + } 349 + 350 + func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 351 + if pull.State == models.PullMerged { 352 + return types.MergeCheckResponse{} 353 + } 354 + 355 + xrpcc := s.knotClient(f.Knot) 356 + 357 + // combine patches of substack 358 + subStack := stack.Below(pull) 359 + // collect the portion of the stack that is mergeable 360 + mergeable := subStack.Mergeable() 361 + // combine each patch 362 + patch := mergeable.CombinedPatch() 363 + 364 + resp, err := tangled.RepoMergeCheck( 365 + r.Context(), 366 + xrpcc, 367 + &tangled.RepoMergeCheck_Input{ 368 + Did: f.Did, 369 + Name: f.Name, 370 + Branch: pull.TargetBranch, 371 + Patch: patch, 372 + }, 373 + ) 374 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 375 + s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 376 + return types.MergeCheckResponse{ 377 + Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 378 + } 379 + } 380 + 381 + return mergeCheckResponseFrom(resp) 382 + } 383 + 384 + func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 385 + conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 386 + for i, c := range resp.Conflicts { 387 + conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 388 + } 389 + out := types.MergeCheckResponse{ 390 + IsConflicted: resp.Is_conflicted, 391 + Conflicts: conflicts, 392 + } 393 + if resp.Message != nil { 394 + out.Message = *resp.Message 395 + } 396 + if resp.Error != nil { 397 + out.Error = *resp.Error 398 + } 399 + return out 400 + } 401 + 402 + func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 403 + if pull.State != models.PullMerged { 404 + return nil 405 + } 406 + 407 + user := s.oauth.GetMultiAccountUser(r) 408 + if user == nil { 409 + return nil 410 + } 411 + 412 + var branch string 413 + // check if the branch exists 414 + // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 415 + if pull.IsBranchBased() { 416 + branch = pull.PullSource.Branch 417 + } else if pull.IsForkBased() { 418 + branch = pull.PullSource.Branch 419 + repo = pull.PullSource.Repo 420 + } else { 421 + return nil 422 + } 423 + 424 + // deleted fork 425 + if repo == nil { 426 + return nil 427 + } 428 + 429 + // user can only delete branch if they are a collaborator in the repo that the branch belongs to 430 + perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier()) 431 + if !slices.Contains(perms, "repo:push") { 432 + return nil 433 + } 434 + 435 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 436 + resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoAt().String()) 437 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 438 + s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 439 + return nil 440 + } 441 + 442 + return &models.BranchDeleteStatus{ 443 + Repo: repo, 444 + Branch: resp.Name, 445 + } 446 + } 447 + 448 + func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 449 + if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 450 + return pages.Unknown 451 + } 452 + 453 + var sourceRepoDid string 454 + if pull.PullSource.RepoDid != nil { 455 + sourceRepoDid = string(*pull.PullSource.RepoDid) 456 + } else { 457 + sourceRepoDid = repo.RepoDid 458 + } 459 + 460 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 461 + branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid) 462 + if err != nil { 463 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 464 + s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 465 + return pages.Unknown 466 + } 467 + s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 468 + return pages.Unknown 469 + } 470 + 471 + targetBranch := branchResp 472 + 473 + top := stack[0] 474 + latestSourceRev := top.LatestSha() 475 + 476 + if latestSourceRev != targetBranch.Hash { 477 + return pages.ShouldResubmit 478 + } 479 + 480 + return pages.ShouldNotResubmit 481 + } 482 + 483 + func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 484 + s.repoPullHelper(w, r, false) 485 + } 486 + 487 + func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 488 + s.repoPullHelper(w, r, true) 489 + } 490 + 491 + func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 492 + l := s.logger.With("handler", "RepoPullPatchRaw") 493 + 494 + pull, ok := r.Context().Value("pull").(*models.Pull) 495 + if !ok { 496 + l.Error("failed to get pull") 497 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 498 + return 499 + } 500 + l = l.With("pull_id", pull.PullId) 501 + 502 + roundId := chi.URLParam(r, "round") 503 + roundIdInt, err := strconv.Atoi(roundId) 504 + if err != nil || roundIdInt >= len(pull.Submissions) { 505 + http.Error(w, "bad round id", http.StatusBadRequest) 506 + l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 507 + return 508 + } 509 + 510 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 511 + w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 512 + } 513 + 514 + func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 515 + l := s.logger.With("handler", "RepoPulls") 516 + 517 + user := s.oauth.GetMultiAccountUser(r) 518 + if user != nil { 519 + l = l.With("user", user.Did) 520 + } 521 + 522 + params := r.URL.Query() 523 + page := pagination.FromContext(r.Context()) 524 + 525 + f, err := s.repoResolver.Resolve(r) 526 + if err != nil { 527 + l.Error("failed to get repo and knot", "err", err) 528 + return 529 + } 530 + l = l.With("repo_at", f.RepoAt().String()) 531 + 532 + query := searchquery.Parse(params.Get("q")) 533 + 534 + var state *models.PullState 535 + if urlState := params.Get("state"); urlState != "" { 536 + switch urlState { 537 + case "open": 538 + state = ptrPullState(models.PullOpen) 539 + case "closed": 540 + state = ptrPullState(models.PullClosed) 541 + case "merged": 542 + state = ptrPullState(models.PullMerged) 543 + } 544 + query.Set("state", urlState) 545 + } else if queryState := query.Get("state"); queryState != nil { 546 + switch *queryState { 547 + case "open": 548 + state = ptrPullState(models.PullOpen) 549 + case "closed": 550 + state = ptrPullState(models.PullClosed) 551 + case "merged": 552 + state = ptrPullState(models.PullMerged) 553 + } 554 + } else if _, hasQ := params["q"]; !hasQ { 555 + state = ptrPullState(models.PullOpen) 556 + query.Set("state", "open") 557 + } 558 + 559 + resolve := func(ctx context.Context, ident string) (string, error) { 560 + id, err := s.idResolver.ResolveIdent(ctx, ident) 561 + if err != nil { 562 + return "", err 563 + } 564 + return id.DID.String(), nil 565 + } 566 + 567 + authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 568 + 569 + labels := query.GetAll("label") 570 + negatedLabels := query.GetAllNegated("label") 571 + labelValues := query.GetDynamicTags() 572 + negatedLabelValues := query.GetNegatedDynamicTags() 573 + 574 + // resolve DID-format label values: if a dynamic tag's label 575 + // definition has format "did", resolve the handle to a DID 576 + if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 577 + labelDefs, err := db.GetLabelDefinitions( 578 + s.db, 579 + orm.FilterIn("at_uri", f.Labels), 580 + orm.FilterContains("scope", tangled.RepoPullNSID), 581 + ) 582 + if err == nil { 583 + didLabels := make(map[string]bool) 584 + for _, def := range labelDefs { 585 + if def.ValueType.Format == models.ValueTypeFormatDid { 586 + didLabels[def.Name] = true 587 + } 588 + } 589 + labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 590 + negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 591 + } else { 592 + l.Debug("failed to fetch label definitions for DID resolution", "err", err) 593 + } 594 + } 595 + 596 + tf := searchquery.ExtractTextFilters(query) 597 + 598 + searchOpts := models.PullSearchOptions{ 599 + Keywords: tf.Keywords, 600 + Phrases: tf.Phrases, 601 + RepoDid: f.RepoDid, 602 + State: state, 603 + AuthorDid: authorDid, 604 + Labels: labels, 605 + LabelValues: labelValues, 606 + NegatedKeywords: tf.NegatedKeywords, 607 + NegatedPhrases: tf.NegatedPhrases, 608 + NegatedLabels: negatedLabels, 609 + NegatedLabelValues: negatedLabelValues, 610 + NegatedAuthorDids: negatedAuthorDids, 611 + Page: page, 612 + } 613 + 614 + var totalPulls int 615 + if state == nil { 616 + totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed 617 + } else { 618 + switch *state { 619 + case models.PullOpen: 620 + totalPulls = f.RepoStats.PullCount.Open 621 + case models.PullMerged: 622 + totalPulls = f.RepoStats.PullCount.Merged 623 + case models.PullClosed: 624 + totalPulls = f.RepoStats.PullCount.Closed 625 + } 626 + } 627 + 628 + repoInfo := s.repoResolver.GetRepoInfo(r, user) 629 + 630 + var pulls []*models.Pull 631 + 632 + if searchOpts.HasSearchFilters() { 633 + res, err := s.indexer.Search(r.Context(), searchOpts) 634 + if err != nil { 635 + l.Error("failed to search for pulls", "err", err) 636 + return 637 + } 638 + totalPulls = int(res.Total) 639 + l.Debug("searched pulls with indexer", "count", len(res.Hits)) 640 + 641 + // update tab counts to reflect filtered results 642 + countOpts := searchOpts 643 + countOpts.Page = pagination.Page{Limit: 1} 644 + for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 645 + countOpts.State = &ps 646 + countRes, err := s.indexer.Search(r.Context(), countOpts) 647 + if err != nil { 648 + continue 649 + } 650 + switch ps { 651 + case models.PullOpen: 652 + repoInfo.Stats.PullCount.Open = int(countRes.Total) 653 + case models.PullMerged: 654 + repoInfo.Stats.PullCount.Merged = int(countRes.Total) 655 + case models.PullClosed: 656 + repoInfo.Stats.PullCount.Closed = int(countRes.Total) 657 + } 658 + } 659 + 660 + if len(res.Hits) > 0 { 661 + pulls, err = db.GetPulls( 662 + s.db, 663 + orm.FilterIn("id", res.Hits), 664 + ) 665 + if err != nil { 666 + l.Error("failed to get pulls", "err", err) 667 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 668 + return 669 + } 670 + } 671 + } else { 672 + filters := []orm.Filter{ 673 + orm.FilterEq("repo_did", f.RepoDid), 674 + } 675 + if state != nil { 676 + filters = append(filters, orm.FilterEq("state", *state)) 677 + } 678 + pulls, err = db.GetPullsPaginated( 679 + s.db, 680 + page, 681 + filters..., 682 + ) 683 + if err != nil { 684 + l.Error("failed to get pulls", "err", err) 685 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 686 + return 687 + } 688 + } 689 + 690 + for _, p := range pulls { 691 + var pullSourceRepo *models.Repo 692 + if p.PullSource != nil { 693 + if p.PullSource.RepoDid != nil { 694 + pullSourceRepo, err = db.GetRepoByDid(s.db, string(*p.PullSource.RepoDid)) 695 + if err != nil { 696 + l.Error("failed to get repo by did", "err", err, "repo_did", p.PullSource.RepoDid.String()) 697 + continue 698 + } else { 699 + p.PullSource.Repo = pullSourceRepo 700 + } 701 + } 702 + } 703 + } 704 + 705 + var stacks []models.Stack 706 + var shas []string 707 + 708 + pullMap := make(map[string]*models.Pull) 709 + for _, p := range pulls { 710 + shas = append(shas, p.LatestSha()) 711 + pullMap[p.AtUri().String()] = p 712 + } 713 + 714 + // track which PRs have been added to stacks 715 + visited := make(map[string]bool) 716 + 717 + // group stacked PRs together using dependent_on relationships 718 + for _, p := range pulls { 719 + if visited[p.AtUri().String()] { 720 + continue 721 + } 722 + 723 + root := p 724 + for root.DependentOn != nil { 725 + if parent, ok := pullMap[root.DependentOn.String()]; ok { 726 + root = parent 727 + } else { 728 + break // parent not in current page 729 + } 730 + } 731 + 732 + var stack models.Stack 733 + current := root 734 + for { 735 + if visited[current.AtUri().String()] { 736 + break 737 + } 738 + stack = append(stack, current) 739 + visited[current.AtUri().String()] = true 740 + 741 + found := false 742 + for _, candidate := range pulls { 743 + if candidate.DependentOn != nil && 744 + candidate.DependentOn.String() == current.AtUri().String() { 745 + current = candidate 746 + found = true 747 + break 748 + } 749 + } 750 + if !found { 751 + break 752 + } 753 + } 754 + 755 + slices.Reverse(stack) 756 + stacks = append(stacks, stack) 757 + } 758 + 759 + ps, err := db.GetPipelineStatuses( 760 + s.db, 761 + len(shas), 762 + orm.FilterEq("p.repo_owner", f.Did), 763 + orm.FilterEq("p.repo_name", f.Rkey), 764 + orm.FilterEq("p.knot", f.Knot), 765 + orm.FilterIn("p.sha", shas), 766 + ) 767 + if err != nil { 768 + l.Warn("failed to fetch pipeline statuses", "err", err) 769 + // non-fatal 770 + } 771 + m := make(map[string]models.Pipeline) 772 + for _, p := range ps { 773 + m[p.Sha] = p 774 + } 775 + 776 + labelDefs, err := db.GetLabelDefinitions( 777 + s.db, 778 + orm.FilterIn("at_uri", f.Labels), 779 + orm.FilterContains("scope", tangled.RepoPullNSID), 780 + ) 781 + if err != nil { 782 + l.Error("failed to fetch labels", "err", err) 783 + s.pages.Error503(w) 784 + return 785 + } 786 + 787 + defs := make(map[string]*models.LabelDefinition) 788 + for _, l := range labelDefs { 789 + defs[l.AtUri().String()] = &l 790 + } 791 + 792 + filterState := "" 793 + if state != nil { 794 + filterState = state.String() 795 + } 796 + 797 + vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 798 + if user != nil { 799 + dids := make([]syntax.DID, len(pulls)) 800 + for i, p := range pulls { 801 + dids[i] = syntax.DID(p.OwnerDid) 802 + } 803 + vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), dids) 804 + if err != nil { 805 + l.Error("failed to fetch vouch relationships", "err", err) 806 + } 807 + } 808 + 809 + err = s.pages.RepoPulls(w, pages.RepoPullsParams{ 810 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 811 + RepoInfo: repoInfo, 812 + Pulls: pulls, 813 + LabelDefs: defs, 814 + FilterState: filterState, 815 + FilterQuery: query.String(), 816 + Stacks: stacks, 817 + Pipelines: m, 818 + Page: page, 819 + PullCount: totalPulls, 820 + VouchRelationships: vouchRelationships, 821 + }) 822 + if err != nil { 823 + l.Error("failed to render page", "err", err) 824 + } 825 + } 826 + 827 + func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 828 + l := s.logger.With("handler", "PullComment") 829 + 830 + user := s.oauth.GetMultiAccountUser(r) 831 + if user != nil { 832 + l = l.With("user", user.Did) 833 + } 834 + 835 + f, err := s.repoResolver.Resolve(r) 836 + if err != nil { 837 + l.Error("failed to get repo and knot", "err", err) 838 + return 839 + } 840 + 841 + pull, ok := r.Context().Value("pull").(*models.Pull) 842 + if !ok { 843 + l.Error("failed to get pull") 844 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 845 + return 846 + } 847 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 848 + 849 + roundNumberStr := chi.URLParam(r, "round") 850 + roundNumber, err := strconv.Atoi(roundNumberStr) 851 + if err != nil || roundNumber >= len(pull.Submissions) { 852 + http.Error(w, "bad round id", http.StatusBadRequest) 853 + l.Error("failed to parse round id", "err", err, "round_number_str", roundNumberStr) 854 + return 855 + } 856 + 857 + switch r.Method { 858 + case http.MethodGet: 859 + s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 860 + LoggedInUser: user, 861 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 862 + Pull: pull, 863 + RoundNumber: roundNumber, 864 + }) 865 + return 866 + case http.MethodPost: 867 + body := r.FormValue("body") 868 + if body == "" { 869 + s.pages.Notice(w, "pull", "Comment body is required") 870 + return 871 + } 872 + 873 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 874 + 875 + // Start a transaction 876 + tx, err := s.db.BeginTx(r.Context(), nil) 877 + if err != nil { 878 + l.Error("failed to start transaction", "err", err) 879 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 880 + return 881 + } 882 + defer tx.Rollback() 883 + 884 + createdAt := time.Now().Format(time.RFC3339) 885 + 886 + client, err := s.oauth.AuthorizedClient(r) 887 + if err != nil { 888 + l.Error("failed to get authorized client", "err", err) 889 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 890 + return 891 + } 892 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 893 + Collection: tangled.RepoPullCommentNSID, 894 + Repo: user.Did, 895 + Rkey: tid.TID(), 896 + Record: &lexutil.LexiconTypeDecoder{ 897 + Val: &tangled.RepoPullComment{ 898 + Pull: pull.AtUri().String(), 899 + Body: body, 900 + CreatedAt: createdAt, 901 + }, 902 + }, 903 + }) 904 + if err != nil { 905 + l.Error("failed to create pull comment", "err", err) 906 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 907 + return 908 + } 909 + 910 + comment := &models.PullComment{ 911 + OwnerDid: user.Did, 912 + RepoDid: string(f.RepoDid), 913 + PullId: pull.PullId, 914 + Body: body, 915 + CommentAt: atResp.Uri, 916 + SubmissionId: pull.Submissions[roundNumber].ID, 917 + Mentions: mentions, 918 + References: references, 919 + } 920 + 921 + // Create the pull comment in the database with the commentAt field 922 + commentId, err := db.NewPullComment(tx, comment) 923 + if err != nil { 924 + l.Error("failed to create pull comment in database", "err", err) 925 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 926 + return 927 + } 928 + 929 + // Commit the transaction 930 + if err = tx.Commit(); err != nil { 931 + l.Error("failed to commit transaction", "err", err) 932 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 933 + return 934 + } 935 + 936 + s.notifier.NewPullComment(r.Context(), comment, mentions) 937 + 938 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 939 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 940 + return 941 + } 942 + } 943 + 944 + func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 945 + l := s.logger.With("handler", "NewPull") 946 + 947 + user := s.oauth.GetMultiAccountUser(r) 948 + if user != nil { 949 + l = l.With("user", user.Did) 950 + } 951 + 952 + f, err := s.repoResolver.Resolve(r) 953 + if err != nil { 954 + l.Error("failed to get repo and knot", "err", err) 955 + return 956 + } 957 + l = l.With("repo_at", f.RepoAt().String()) 958 + 959 + switch r.Method { 960 + case http.MethodGet: 961 + params, err := s.composeParams(r, f) 962 + if err != nil { 963 + l.Error("failed to build compose params", "err", err) 964 + s.pages.Error503(w) 965 + return 966 + } 967 + s.pages.RepoNewPull(w, params) 968 + 969 + case http.MethodPost: 970 + title := r.FormValue("title") 971 + body := r.FormValue("body") 972 + targetBranch := r.FormValue("targetBranch") 973 + fromFork := r.FormValue("fork") 974 + sourceBranch := r.FormValue("sourceBranch") 975 + patch := r.FormValue("patch") 976 + userDid := syntax.DID(user.Did) 977 + 978 + if targetBranch == "" { 979 + s.pages.Notice(w, "pull", "Target branch is required.") 980 + return 981 + } 982 + 983 + // Determine PR type based on input parameters 984 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(userDid.String(), f.Knot, f.RepoIdentifier())} 985 + isPushAllowed := roles.IsPushAllowed() 986 + isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 987 + isForkBased := fromFork != "" && sourceBranch != "" 988 + isPatchBased := patch != "" && !isBranchBased && !isForkBased 989 + isStacked := r.FormValue("mode") == "stack" && !isPatchBased 990 + 991 + if isPatchBased && !patchutil.IsFormatPatch(patch) { 992 + if title == "" { 993 + s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 994 + return 995 + } 996 + sanitizer := markup.NewSanitizer() 997 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 998 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 999 + return 1000 + } 1001 + } 1002 + 1003 + // Validate we have at least one valid PR creation method 1004 + if !isBranchBased && !isPatchBased && !isForkBased { 1005 + s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 1006 + return 1007 + } 1008 + 1009 + // Can't mix branch-based and patch-based approaches 1010 + if isBranchBased && patch != "" { 1011 + s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 1012 + return 1013 + } 1014 + 1015 + if isBranchBased && sourceBranch == targetBranch { 1016 + s.pages.Notice(w, "pull", "Source and target branch must be different.") 1017 + return 1018 + } 1019 + 1020 + // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1021 + // if err != nil { 1022 + // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 1023 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1024 + // return 1025 + // } 1026 + 1027 + // TODO: make capabilities an xrpc call 1028 + caps := struct { 1029 + PullRequests struct { 1030 + FormatPatch bool 1031 + BranchSubmissions bool 1032 + ForkSubmissions bool 1033 + PatchSubmissions bool 1034 + } 1035 + }{ 1036 + PullRequests: struct { 1037 + FormatPatch bool 1038 + BranchSubmissions bool 1039 + ForkSubmissions bool 1040 + PatchSubmissions bool 1041 + }{ 1042 + FormatPatch: true, 1043 + BranchSubmissions: true, 1044 + ForkSubmissions: true, 1045 + PatchSubmissions: true, 1046 + }, 1047 + } 1048 + 1049 + // caps, err := us.Capabilities() 1050 + // if err != nil { 1051 + // log.Println("error fetching knot caps", f.Knot, err) 1052 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1053 + // return 1054 + // } 1055 + 1056 + if !caps.PullRequests.FormatPatch { 1057 + s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 1058 + return 1059 + } 1060 + 1061 + stackTitles := parseBracketedForm(r.Form, "stackTitle") 1062 + stackBodies := parseBracketedForm(r.Form, "stackBody") 1063 + 1064 + // Handle the PR creation based on the type 1065 + if isBranchBased { 1066 + if !caps.PullRequests.BranchSubmissions { 1067 + s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 1068 + return 1069 + } 1070 + s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1071 + } else if isForkBased { 1072 + if !caps.PullRequests.ForkSubmissions { 1073 + s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 1074 + return 1075 + } 1076 + s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1077 + } else if isPatchBased { 1078 + if !caps.PullRequests.PatchSubmissions { 1079 + s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 1080 + return 1081 + } 1082 + s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) 1083 + } 1084 + return 1085 + } 1086 + } 1087 + 1088 + func (s *Pulls) handleBranchBasedPull( 1089 + w http.ResponseWriter, 1090 + r *http.Request, 1091 + repo *models.Repo, 1092 + userDid syntax.DID, 1093 + title, 1094 + body, 1095 + targetBranch, 1096 + sourceBranch string, 1097 + isStacked bool, 1098 + stackTitles, stackBodies map[string]string, 1099 + ) { 1100 + l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1101 + 1102 + xrpcc := s.knotClient(repo.Knot) 1103 + 1104 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 1105 + if err != nil { 1106 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1107 + l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err) 1108 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1109 + return 1110 + } 1111 + l.Error("failed to compare", "err", err) 1112 + s.pages.Notice(w, "pull", err.Error()) 1113 + return 1114 + } 1115 + 1116 + var comparison types.RepoFormatPatchResponse 1117 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1118 + l.Error("failed to decode XRPC compare response", "err", err) 1119 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1120 + return 1121 + } 1122 + 1123 + if len(comparison.FormatPatch) == 0 { 1124 + s.pages.Notice(w, "pull", "No commits between target and source.") 1125 + return 1126 + } 1127 + 1128 + sourceRev := comparison.Rev2 1129 + patch := comparison.FormatPatchRaw 1130 + combined := comparison.CombinedPatchRaw 1131 + 1132 + if err := s.validator.ValidatePatch(&patch); err != nil { 1133 + s.logger.Error("failed to validate patch", "err", err) 1134 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1135 + return 1136 + } 1137 + 1138 + pullSource := &models.PullSource{ 1139 + Branch: sourceBranch, 1140 + } 1141 + 1142 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1143 + } 1144 + 1145 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) { 1146 + if err := s.validator.ValidatePatch(&patch); err != nil { 1147 + s.logger.Error("patch validation failed", "err", err) 1148 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1149 + return 1150 + } 1151 + 1152 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies) 1153 + } 1154 + 1155 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) { 1156 + l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo", forkRepo, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1157 + 1158 + repoString := strings.SplitN(forkRepo, "/", 2) 1159 + forkOwnerDid := repoString[0] 1160 + forkRkey := strings.ToLower(repoString[1]) 1161 + fork, err := db.GetForkByDid(s.db, forkOwnerDid, forkRkey) 1162 + if errors.Is(err, sql.ErrNoRows) { 1163 + s.pages.Notice(w, "pull", "No such fork.") 1164 + return 1165 + } else if err != nil { 1166 + l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "fork_rkey", forkRkey) 1167 + s.pages.Notice(w, "pull", "Failed to fetch fork.") 1168 + return 1169 + } 1170 + 1171 + client, err := s.oauth.ServiceClient( 1172 + r, 1173 + oauth.WithService(fork.Knot), 1174 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 1175 + oauth.WithDev(s.config.Core.Dev), 1176 + ) 1177 + 1178 + resp, err := tangled.RepoHiddenRef( 1179 + r.Context(), 1180 + client, 1181 + &tangled.RepoHiddenRef_Input{ 1182 + ForkRef: sourceBranch, 1183 + RemoteRef: targetBranch, 1184 + Repo: fork.RepoAt().String(), 1185 + }, 1186 + ) 1187 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1188 + s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 1189 + s.pages.Notice(w, "pull", xrpcerr.Error()) 1190 + return 1191 + } 1192 + 1193 + if !resp.Success { 1194 + errorMsg := "Failed to create pull request" 1195 + if resp.Error != nil { 1196 + errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 1197 + } 1198 + s.pages.Notice(w, "pull", errorMsg) 1199 + return 1200 + } 1201 + 1202 + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 1203 + // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 1204 + // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 1205 + // hiddenRef: hidden/feature-1/main (on repo-fork) 1206 + // targetBranch: main (on repo-1) 1207 + // sourceBranch: feature-1 (on repo-fork) 1208 + forkXrpcc := s.knotClient(fork.Knot) 1209 + 1210 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 1211 + if err != nil { 1212 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1213 + l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef) 1214 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1215 + return 1216 + } 1217 + l.Error("failed to compare across branches", "err", err, "hidden_ref", hiddenRef) 1218 + s.pages.Notice(w, "pull", err.Error()) 1219 + return 1220 + } 1221 + 1222 + var comparison types.RepoFormatPatchResponse 1223 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 1224 + l.Error("failed to decode XRPC compare response for fork", "err", err) 1225 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1226 + return 1227 + } 1228 + 1229 + if len(comparison.FormatPatch) == 0 { 1230 + s.pages.Notice(w, "pull", "No commits between target and source.") 1231 + return 1232 + } 1233 + 1234 + sourceRev := comparison.Rev2 1235 + patch := comparison.FormatPatchRaw 1236 + combined := comparison.CombinedPatchRaw 1237 + 1238 + if err := s.validator.ValidatePatch(&patch); err != nil { 1239 + s.logger.Error("failed to validate patch", "err", err) 1240 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1241 + return 1242 + } 1243 + 1244 + forkDid := syntax.DID(fork.RepoDid) 1245 + pullSource := &models.PullSource{ 1246 + Branch: sourceBranch, 1247 + RepoDid: &forkDid, 1248 + } 1249 + 1250 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1251 + } 1252 + 1253 + func (s *Pulls) createPullRequest( 1254 + w http.ResponseWriter, 1255 + r *http.Request, 1256 + repo *models.Repo, 1257 + userDid syntax.DID, 1258 + title, body, targetBranch string, 1259 + patch string, 1260 + combined string, 1261 + sourceRev string, 1262 + pullSource *models.PullSource, 1263 + isStacked bool, 1264 + stackTitles, stackBodies map[string]string, 1265 + ) { 1266 + l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked) 1267 + 1268 + if isStacked { 1269 + // creates a series of PRs, each linking to the previous, identified by jj's change-id 1270 + s.createStackedPullRequest( 1271 + w, 1272 + r, 1273 + repo, 1274 + userDid, 1275 + targetBranch, 1276 + patch, 1277 + sourceRev, 1278 + pullSource, 1279 + stackTitles, 1280 + stackBodies, 1281 + ) 1282 + return 1283 + } 1284 + 1285 + client, err := s.oauth.AuthorizedClient(r) 1286 + if err != nil { 1287 + l.Error("failed to get authorized client", "err", err) 1288 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1289 + return 1290 + } 1291 + 1292 + tx, err := s.db.BeginTx(r.Context(), nil) 1293 + if err != nil { 1294 + l.Error("failed to start tx", "err", err) 1295 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1296 + return 1297 + } 1298 + defer tx.Rollback() 1299 + 1300 + // We've already checked earlier if it's diff-based and title is empty, 1301 + // so if it's still empty now, it's intentionally skipped owing to format-patch. 1302 + if title == "" || body == "" { 1303 + formatPatches, err := patchutil.ExtractPatches(patch) 1304 + if err != nil { 1305 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1306 + return 1307 + } 1308 + if len(formatPatches) == 0 { 1309 + s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 1310 + return 1311 + } 1312 + 1313 + if title == "" { 1314 + title = formatPatches[0].Title 1315 + } 1316 + if body == "" { 1317 + body = formatPatches[0].Body 1318 + } 1319 + } 1320 + 1321 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1322 + 1323 + rkey := tid.TID() 1324 + 1325 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1326 + if err != nil { 1327 + l.Error("failed to upload patch", "err", err) 1328 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1329 + return 1330 + } 1331 + 1332 + now := time.Now() 1333 + 1334 + pull := &models.Pull{ 1335 + Title: title, 1336 + Body: body, 1337 + TargetBranch: targetBranch, 1338 + OwnerDid: userDid.String(), 1339 + RepoDid: syntax.DID(repo.RepoDid), 1340 + Rkey: rkey, 1341 + Mentions: mentions, 1342 + References: references, 1343 + Submissions: []*models.PullSubmission{ 1344 + { 1345 + Patch: patch, 1346 + Combined: combined, 1347 + SourceRev: sourceRev, 1348 + Blob: *blob.Blob, 1349 + Created: now, 1350 + }, 1351 + }, 1352 + PullSource: pullSource, 1353 + State: models.PullOpen, 1354 + Created: now, 1355 + } 1356 + 1357 + record := pull.AsRecord() 1358 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1359 + Collection: tangled.RepoPullNSID, 1360 + Repo: userDid.String(), 1361 + Rkey: rkey, 1362 + Record: &lexutil.LexiconTypeDecoder{ 1363 + Val: &record, 1364 + }, 1365 + }) 1366 + if err != nil { 1367 + l.Error("failed to create pull request", "err", err) 1368 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1369 + return 1370 + } 1371 + 1372 + err = db.PutPull(tx, pull) 1373 + if err != nil { 1374 + l.Error("failed to create pull request in database", "err", err) 1375 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1376 + return 1377 + } 1378 + pullId, err := db.NextPullId(tx, repo.RepoDid) 1379 + if err != nil { 1380 + s.logger.Error("failed to get pull id", "err", err) 1381 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1382 + return 1383 + } 1384 + 1385 + if err = tx.Commit(); err != nil { 1386 + l.Error("failed to commit transaction for pull request", "err", err) 1387 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1388 + return 1389 + } 1390 + 1391 + s.notifier.NewPull(r.Context(), pull) 1392 + 1393 + s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo) 1394 + 1395 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1396 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1397 + } 1398 + 1399 + func (s *Pulls) createStackedPullRequest( 1400 + w http.ResponseWriter, 1401 + r *http.Request, 1402 + repo *models.Repo, 1403 + userDid syntax.DID, 1404 + targetBranch string, 1405 + patch string, 1406 + sourceRev string, 1407 + pullSource *models.PullSource, 1408 + stackTitles, stackBodies map[string]string, 1409 + ) { 1410 + l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev) 1411 + 1412 + // run some necessary checks for stacked-prs first 1413 + 1414 + formatPatches, err := patchutil.ExtractPatches(patch) 1415 + if err != nil { 1416 + l.Error("failed to extract patches", "err", err) 1417 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1418 + return 1419 + } 1420 + 1421 + // must have atleast 1 patch to begin with 1422 + if len(formatPatches) == 0 { 1423 + l.Error("empty patches") 1424 + s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1425 + return 1426 + } 1427 + 1428 + client, err := s.oauth.AuthorizedClient(r) 1429 + if err != nil { 1430 + l.Error("failed to get authorized client", "err", err) 1431 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1432 + return 1433 + } 1434 + 1435 + // first upload all blobs 1436 + blobs := make([]*lexutil.LexBlob, len(formatPatches)) 1437 + for i, p := range formatPatches { 1438 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 1439 + if err != nil { 1440 + l.Error("failed to upload patch blob", "err", err, "patch_index", i) 1441 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1442 + return 1443 + } 1444 + l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 1445 + blobs[i] = blob.Blob 1446 + } 1447 + 1448 + // build a stack out of this patch 1449 + stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies) 1450 + if err != nil { 1451 + l.Error("failed to create stack", "err", err) 1452 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1453 + return 1454 + } 1455 + 1456 + // apply all record creations at once 1457 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1458 + for _, p := range stack { 1459 + record := p.AsRecord() 1460 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1461 + RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1462 + Collection: tangled.RepoPullNSID, 1463 + Rkey: &p.Rkey, 1464 + Value: &lexutil.LexiconTypeDecoder{ 1465 + Val: &record, 1466 + }, 1467 + }, 1468 + }) 1469 + } 1470 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1471 + Repo: userDid.String(), 1472 + Writes: writes, 1473 + }) 1474 + if err != nil { 1475 + l.Error("failed to create stacked pull request", "err", err) 1476 + s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1477 + return 1478 + } 1479 + 1480 + // create all pulls at once 1481 + tx, err := s.db.BeginTx(r.Context(), nil) 1482 + if err != nil { 1483 + l.Error("failed to start tx", "err", err) 1484 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1485 + return 1486 + } 1487 + defer tx.Rollback() 1488 + 1489 + for _, p := range stack { 1490 + err = db.PutPull(tx, p) 1491 + if err != nil { 1492 + l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey) 1493 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1494 + return 1495 + } 1496 + 1497 + } 1498 + 1499 + if err = tx.Commit(); err != nil { 1500 + l.Error("failed to commit transaction for pull requests", "err", err) 1501 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1502 + return 1503 + } 1504 + 1505 + // notify about each pull 1506 + // 1507 + // this is performed after tx.Commit, because it could result in a locked DB otherwise 1508 + for _, p := range stack { 1509 + s.notifier.NewPull(r.Context(), p) 1510 + } 1511 + 1512 + s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo) 1513 + 1514 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1515 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1516 + } 1517 + 1518 + func (s *Pulls) MarkdownPreview(w http.ResponseWriter, r *http.Request) { 1519 + body := r.FormValue("body") 1520 + s.pages.MarkdownPreviewFragment(w, body) 1521 + } 1522 + 1523 + func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { 1524 + l := s.logger.With("handler", "RefreshCompose") 1525 + 1526 + f, err := s.repoResolver.Resolve(r) 1527 + if err != nil { 1528 + l.Error("failed to resolve repo", "err", err) 1529 + s.pages.Error503(w) 1530 + return 1531 + } 1532 + 1533 + params, err := s.composeParams(r, f) 1534 + if err != nil { 1535 + l.Error("failed to build compose params", "err", err) 1536 + s.pages.Error503(w) 1537 + return 1538 + } 1539 + w.Header().Set("HX-Replace-Url", composeCanonicalURL(params)) 1540 + s.pages.PullComposeHostFragment(w, params) 1541 + } 1542 + 1543 + func composeCanonicalURL(params pages.RepoNewPullParams) string { 1544 + base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 1545 + q := url.Values{} 1546 + if params.IsStacked { 1547 + q.Set("mode", "stack") 1548 + } 1549 + if params.Source != "" && params.Source != pages.SourceBranch { 1550 + q.Set("source", string(params.Source)) 1551 + } 1552 + if params.SourceBranch != "" { 1553 + q.Set("sourceBranch", params.SourceBranch) 1554 + } 1555 + if params.TargetBranch != "" { 1556 + q.Set("targetBranch", params.TargetBranch) 1557 + } 1558 + if params.Source == pages.SourceFork && params.Fork != "" { 1559 + q.Set("fork", params.Fork) 1560 + } 1561 + if len(q) == 0 { 1562 + return base 1563 + } 1564 + return base + "?" + q.Encode() 1565 + } 1566 + 1567 + func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) { 1568 + l := s.logger.With("handler", "composeParams") 1569 + user := s.oauth.GetMultiAccountUser(r) 1570 + 1571 + branches, err := s.listBranches(r.Context(), repo) 1572 + if err != nil { 1573 + return pages.RepoNewPullParams{}, err 1574 + } 1575 + 1576 + var forks []models.Repo 1577 + if user != nil { 1578 + forks, err = db.GetForksByDid(s.db, user.Did) 1579 + if err != nil { 1580 + l.Warn("failed to list user forks", "err", err, "user", user.Did) 1581 + } 1582 + } 1583 + 1584 + repoInfo := s.repoResolver.GetRepoInfo(r, user) 1585 + source, ok := pages.ParseSource(r.FormValue("source")) 1586 + if !ok { 1587 + source = pages.SourceBranch 1588 + if !repoInfo.Roles.IsPushAllowed() { 1589 + source = pages.SourceFork 1590 + } 1591 + } 1592 + 1593 + sourceBranch := r.FormValue("sourceBranch") 1594 + targetBranch := r.FormValue("targetBranch") 1595 + fork := r.FormValue("fork") 1596 + patch := r.FormValue("patch") 1597 + 1598 + if source == pages.SourceFork && fork == "" && len(forks) == 1 { 1599 + fork = fmt.Sprintf("%s/%s", forks[0].Did, forks[0].Name) 1600 + } 1601 + 1602 + var forkBranches []types.Branch 1603 + var forkBranchesErr error 1604 + if source == pages.SourceFork && fork != "" { 1605 + forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) 1606 + if forkBranchesErr != nil { 1607 + l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) 1608 + } 1609 + } 1610 + 1611 + sourceBranchList := sourceBranchChoices(branches) 1612 + targetBranch = defaultTargetBranch(branches, targetBranch) 1613 + sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 1614 + 1615 + comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 1616 + var prefillErr string 1617 + if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { 1618 + prefillErr = joined.Error() 1619 + } 1620 + 1621 + mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) 1622 + 1623 + refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 1624 + var diffOpts types.DiffOpts 1625 + if r.FormValue("diff") == "split" { 1626 + diffOpts.Split = true 1627 + } 1628 + diffOpts.RefreshUrl = refreshUrl 1629 + diffOpts.Target = "#diff-area" 1630 + 1631 + labelDefs, err := s.pullLabelDefs(repo) 1632 + if err != nil { 1633 + l.Warn("failed to load label definitions", "err", err) 1634 + } 1635 + labelState := labelStateFromForm(r.Form, labelDefs) 1636 + perCidLabelForms := parseStackLabelForms(r.Form) 1637 + stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 1638 + for cid, perForm := range perCidLabelForms { 1639 + stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 1640 + } 1641 + 1642 + stackTitles := parseBracketedForm(r.Form, "stackTitle") 1643 + stackBodies := parseBracketedForm(r.Form, "stackBody") 1644 + stackSplits := parseBracketedForm(r.Form, "stackSplit") 1645 + 1646 + title := r.FormValue("title") 1647 + body := r.FormValue("body") 1648 + if comparison != nil && len(comparison.FormatPatch) > 0 { 1649 + first := comparison.FormatPatch[0] 1650 + if title == "" && first.PatchHeader != nil { 1651 + title = first.Title 1652 + } 1653 + if body == "" && first.PatchHeader != nil { 1654 + body = first.Body 1655 + } 1656 + } 1657 + 1658 + isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 1659 + var stackedDiffs []pages.StackedDiff 1660 + if isStacked { 1661 + stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 1662 + } 1663 + 1664 + return pages.RepoNewPullParams{ 1665 + LoggedInUser: user, 1666 + RepoInfo: repoInfo, 1667 + Branches: branches, 1668 + SourceBranches: sourceBranchList, 1669 + ForkBranches: forkBranches, 1670 + Forks: forks, 1671 + Source: source, 1672 + SourceBranch: sourceBranch, 1673 + TargetBranch: targetBranch, 1674 + Fork: fork, 1675 + Patch: patch, 1676 + Title: title, 1677 + Body: body, 1678 + IsStacked: isStacked, 1679 + Comparison: comparison, 1680 + Diff: diff, 1681 + DiffOpts: diffOpts, 1682 + StackedDiffs: stackedDiffs, 1683 + MergeCheck: mergeCheck, 1684 + StackTitles: stackTitles, 1685 + StackBodies: stackBodies, 1686 + PrefillError: prefillErr, 1687 + LabelDefs: labelDefs, 1688 + LabelState: labelState, 1689 + StackLabelStates: stackLabelStates, 1690 + }, nil 1691 + } 1692 + 1693 + func (s *Pulls) pullLabelDefs(repo *models.Repo) (map[string]*models.LabelDefinition, error) { 1694 + defs, err := db.GetLabelDefinitions( 1695 + s.db, 1696 + orm.FilterIn("at_uri", repo.Labels), 1697 + orm.FilterContains("scope", tangled.RepoPullNSID), 1698 + ) 1699 + if err != nil { 1700 + return nil, err 1701 + } 1702 + 1703 + out := make(map[string]*models.LabelDefinition, len(defs)) 1704 + for i := range defs { 1705 + d := defs[i] 1706 + if !slices.Contains(d.Scope, tangled.RepoPullNSID) { 1707 + continue 1708 + } 1709 + out[d.AtUri().String()] = &d 1710 + } 1711 + return out, nil 1712 + } 1713 + 1714 + func formLabelEntries(form url.Values, defs map[string]*models.LabelDefinition) iter.Seq2[string, string] { 1715 + return func(yield func(string, string) bool) { 1716 + for key := range defs { 1717 + for _, v := range form[key] { 1718 + if v == "" { 1719 + continue 1720 + } 1721 + if !yield(key, v) { 1722 + return 1723 + } 1724 + } 1725 + } 1726 + } 1727 + } 1728 + 1729 + func labelStateFromForm(form url.Values, defs map[string]*models.LabelDefinition) models.LabelState { 1730 + state := models.NewLabelState() 1731 + actx := &models.LabelApplicationCtx{Defs: defs} 1732 + for key, val := range formLabelEntries(form, defs) { 1733 + _ = actx.ApplyLabelOp(state, models.LabelOp{ 1734 + Operation: models.LabelOperationAdd, 1735 + OperandKey: key, 1736 + OperandValue: val, 1737 + }) 1738 + } 1739 + return state 1740 + } 1741 + 1742 + func buildCreationLabelOps( 1743 + userDid syntax.DID, 1744 + subject syntax.ATURI, 1745 + rkey string, 1746 + form url.Values, 1747 + defs map[string]*models.LabelDefinition, 1748 + performedAt time.Time, 1749 + ) []models.LabelOp { 1750 + var ops []models.LabelOp 1751 + for key, val := range formLabelEntries(form, defs) { 1752 + ops = append(ops, models.LabelOp{ 1753 + Did: userDid.String(), 1754 + Rkey: rkey, 1755 + Subject: subject, 1756 + Operation: models.LabelOperationAdd, 1757 + OperandKey: key, 1758 + OperandValue: val, 1759 + PerformedAt: performedAt, 1760 + }) 1761 + } 1762 + return ops 1763 + } 1764 + 1765 + func (s *Pulls) applyCreationLabels( 1766 + ctx context.Context, 1767 + client *atclient.APIClient, 1768 + userDid syntax.DID, 1769 + pulls []*models.Pull, 1770 + form url.Values, 1771 + repo *models.Repo, 1772 + ) { 1773 + l := s.logger.With("handler", "applyCreationLabels", "user", userDid) 1774 + 1775 + defs, err := s.pullLabelDefs(repo) 1776 + if err != nil { 1777 + l.Warn("failed to fetch label defs", "err", err) 1778 + return 1779 + } 1780 + if len(defs) == 0 { 1781 + return 1782 + } 1783 + 1784 + perCidForms := parseStackLabelForms(form) 1785 + 1786 + applyAll := form.Get("applyLabelsToAll") == "on" 1787 + var firstStackForm url.Values 1788 + if applyAll && len(pulls) > 0 && len(pulls[0].Submissions) > 0 { 1789 + if firstCid := pulls[0].Submissions[0].ChangeId(); firstCid != "" { 1790 + if f, ok := perCidForms[firstCid]; ok { 1791 + firstStackForm = f 1792 + } 1793 + } 1794 + } 1795 + 1796 + performedAt := time.Now() 1797 + for _, pull := range pulls { 1798 + labelForm := form 1799 + if firstStackForm != nil { 1800 + labelForm = firstStackForm 1801 + } else if len(perCidForms) > 0 && len(pull.Submissions) > 0 { 1802 + if cid := pull.Submissions[0].ChangeId(); cid != "" { 1803 + if perForm, ok := perCidForms[cid]; ok { 1804 + labelForm = perForm 1805 + } 1806 + } 1807 + } 1808 + rkey := tid.TID() 1809 + raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt) 1810 + 1811 + valid := make([]models.LabelOp, 0, len(raw)) 1812 + for _, op := range raw { 1813 + def := defs[op.OperandKey] 1814 + if err := s.validator.ValidateLabelOp(def, repo, &op); err != nil { 1815 + l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 1816 + continue 1817 + } 1818 + valid = append(valid, op) 1819 + } 1820 + if len(valid) == 0 { 1821 + continue 1822 + } 1823 + 1824 + record := models.LabelOpsAsRecord(valid) 1825 + if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 1826 + Collection: tangled.LabelOpNSID, 1827 + Repo: userDid.String(), 1828 + Rkey: rkey, 1829 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 1830 + }); err != nil { 1831 + l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 1832 + continue 1833 + } 1834 + 1835 + if err := s.indexLabelOps(ctx, valid); err != nil { 1836 + l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 1837 + if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 1838 + Collection: tangled.LabelOpNSID, 1839 + Repo: userDid.String(), 1840 + Rkey: rkey, 1841 + }); err != nil { 1842 + l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 1843 + } 1844 + continue 1845 + } 1846 + 1847 + s.notifier.NewPullLabelOp(ctx, pull) 1848 + } 1849 + } 1850 + 1851 + func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error { 1852 + tx, err := s.db.BeginTx(ctx, nil) 1853 + if err != nil { 1854 + return err 1855 + } 1856 + defer tx.Rollback() 1857 + for _, op := range ops { 1858 + if _, err := db.AddLabelOp(tx, &op); err != nil { 1859 + return err 1860 + } 1861 + } 1862 + return tx.Commit() 1863 + } 1864 + 1865 + func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 1866 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1867 + xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoAt().String()) 1868 + if err != nil { 1869 + return nil, err 1870 + } 1871 + var result types.RepoBranchesResponse 1872 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1873 + return nil, err 1874 + } 1875 + return result.Branches, nil 1876 + } 1877 + 1878 + func (s *Pulls) listForkBranches(ctx context.Context, forkIdent string) ([]types.Branch, error) { 1879 + parts := strings.SplitN(forkIdent, "/", 2) 1880 + if len(parts) != 2 { 1881 + return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 1882 + } 1883 + forkRepo, err := db.GetRepo(s.db, orm.FilterEq("did", parts[0]), orm.FilterEq("name", parts[1])) 1884 + if err != nil { 1885 + return nil, err 1886 + } 1887 + branches, err := s.listBranches(ctx, forkRepo) 1888 + if err != nil { 1889 + return nil, err 1890 + } 1891 + return sortBranchesByRecency(branches), nil 1892 + } 1893 + 1894 + func sourceBranchChoices(branches []types.Branch) []types.Branch { 1895 + withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { 1896 + return b.IsDefault 1897 + }) 1898 + return sortBranchesByRecency(withoutDefault) 1899 + } 1900 + 1901 + func defaultTargetBranch(branches []types.Branch, current string) string { 1902 + if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { 1903 + return current 1904 + } 1905 + if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { 1906 + return branches[idx].Reference.Name 1907 + } 1908 + return "" 1909 + } 1910 + 1911 + func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { 1912 + var candidates []types.Branch 1913 + switch source { 1914 + case pages.SourceFork: 1915 + candidates = forkBranches 1916 + case pages.SourceBranch: 1917 + candidates = branchChoices 1918 + default: 1919 + return current 1920 + } 1921 + if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { 1922 + return current 1923 + } 1924 + if len(candidates) == 0 { 1925 + return "" 1926 + } 1927 + return candidates[0].Reference.Name 1928 + } 1929 + 1930 + func sortBranchesByRecency(branches []types.Branch) []types.Branch { 1931 + out := slices.Clone(branches) 1932 + sort.SliceStable(out, func(i, j int) bool { 1933 + if out[i].Commit == nil || out[j].Commit == nil { 1934 + return out[i].Commit != nil 1935 + } 1936 + return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) 1937 + }) 1938 + return out 1939 + } 1940 + 1941 + func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 1942 + var ( 1943 + comparison *types.RepoFormatPatchResponse 1944 + err error 1945 + ) 1946 + switch source { 1947 + case pages.SourcePatch: 1948 + if strings.TrimSpace(patch) == "" { 1949 + return nil, nil, nil 1950 + } 1951 + if verr := s.validator.ValidatePatch(&patch); verr != nil { 1952 + return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 1953 + } 1954 + comparison = parsePastedPatch(patch) 1955 + case pages.SourceBranch: 1956 + if targetBranch == "" || sourceBranch == "" { 1957 + return nil, nil, nil 1958 + } 1959 + comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 1960 + case pages.SourceFork: 1961 + if fork == "" || targetBranch == "" || sourceBranch == "" { 1962 + return nil, nil, nil 1963 + } 1964 + comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 1965 + default: 1966 + return nil, nil, nil 1967 + } 1968 + if err != nil { 1969 + s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 1970 + return nil, nil, err 1971 + } 1972 + 1973 + return comparison, deriveDiff(comparison, targetBranch), nil 1974 + } 1975 + 1976 + func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 1977 + if comparison == nil || targetBranch == "" { 1978 + return nil 1979 + } 1980 + patch := comparison.CombinedPatchRaw 1981 + if patch == "" { 1982 + patch = comparison.FormatPatchRaw 1983 + } 1984 + if patch == "" { 1985 + return nil 1986 + } 1987 + 1988 + xrpcc := s.knotClient(repo.Knot) 1989 + 1990 + resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 1991 + Did: repo.Did, 1992 + Name: repo.Name, 1993 + Branch: targetBranch, 1994 + Patch: patch, 1995 + }) 1996 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1997 + s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 1998 + return &types.MergeCheckResponse{Error: xrpcerr.Error()} 1999 + } 2000 + 2001 + out := mergeCheckResponseFrom(resp) 2002 + return &out 2003 + } 2004 + 2005 + func bracketComponents(key, prefix string) ([]string, bool) { 2006 + if !strings.HasPrefix(key, prefix) { 2007 + return nil, false 2008 + } 2009 + rest := key[len(prefix):] 2010 + var parts []string 2011 + for len(rest) > 0 { 2012 + if !strings.HasPrefix(rest, "[") { 2013 + return nil, false 2014 + } 2015 + end := strings.Index(rest, "]") 2016 + if end <= 0 { 2017 + return nil, false 2018 + } 2019 + parts = append(parts, rest[1:end]) 2020 + rest = rest[end+1:] 2021 + } 2022 + if len(parts) == 0 { 2023 + return nil, false 2024 + } 2025 + return parts, true 2026 + } 2027 + 2028 + func parseBracketedForm(form url.Values, prefix string) map[string]string { 2029 + out := make(map[string]string) 2030 + for key, vals := range form { 2031 + parts, ok := bracketComponents(key, prefix) 2032 + if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { 2033 + continue 2034 + } 2035 + out[parts[0]] = vals[0] 2036 + } 2037 + return out 2038 + } 2039 + 2040 + func parseStackLabelForms(form url.Values) map[string]url.Values { 2041 + out := make(map[string]url.Values) 2042 + for key, vals := range form { 2043 + parts, ok := bracketComponents(key, "stackLabel") 2044 + if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { 2045 + continue 2046 + } 2047 + cid, atUri := parts[0], parts[1] 2048 + if _, ok := out[cid]; !ok { 2049 + out[cid] = make(url.Values) 2050 + } 2051 + out[cid][atUri] = append(out[cid][atUri], vals...) 2052 + } 2053 + return out 2054 + } 2055 + 2056 + func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 2057 + if patch == "" { 2058 + return nil 2059 + } 2060 + response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 2061 + if patchutil.IsFormatPatch(patch) { 2062 + if patches, err := patchutil.ExtractPatches(patch); err == nil { 2063 + response.FormatPatch = patches 2064 + } 2065 + } 2066 + return response 2067 + } 2068 + 2069 + func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2070 + xrpcc := s.knotClient(repo.Knot) 2071 + 2072 + xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 2073 + if err != nil { 2074 + return nil, err 2075 + } 2076 + 2077 + var comparison types.RepoFormatPatchResponse 2078 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 2079 + return nil, err 2080 + } 2081 + return &comparison, nil 2082 + } 2083 + 2084 + func (s *Pulls) fetchForkComparison(r *http.Request, forkIdent, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2085 + parts := strings.SplitN(forkIdent, "/", 2) 2086 + if len(parts) != 2 { 2087 + return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 2088 + } 2089 + fork, err := db.GetForkByDid(s.db, parts[0], parts[1]) 2090 + if err != nil { 2091 + return nil, err 2092 + } 2093 + 2094 + client, err := s.oauth.ServiceClient( 2095 + r, 2096 + oauth.WithService(fork.Knot), 2097 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 2098 + oauth.WithDev(s.config.Core.Dev), 2099 + ) 2100 + if err != nil { 2101 + return nil, err 2102 + } 2103 + 2104 + resp, err := tangled.RepoHiddenRef( 2105 + r.Context(), 2106 + client, 2107 + &tangled.RepoHiddenRef_Input{ 2108 + ForkRef: sourceBranch, 2109 + RemoteRef: targetBranch, 2110 + Repo: fork.RepoAt().String(), 2111 + }, 2112 + ) 2113 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2114 + return nil, xrpcerr 2115 + } 2116 + if !resp.Success { 2117 + if resp.Error != nil { 2118 + return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 2119 + } 2120 + return nil, fmt.Errorf("hidden ref failed") 2121 + } 2122 + 2123 + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 2124 + forkXrpcc := s.knotClient(fork.Knot) 2125 + 2126 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 2127 + if err != nil { 2128 + return nil, err 2129 + } 2130 + 2131 + var comparison types.RepoFormatPatchResponse 2132 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 2133 + return nil, err 2134 + } 2135 + return &comparison, nil 2136 + } 2137 + 2138 + func stackPerCommitDiffs( 2139 + comparison *types.RepoFormatPatchResponse, 2140 + targetBranch, refreshUrl string, 2141 + stackSplits map[string]string, 2142 + ) []pages.StackedDiff { 2143 + if comparison == nil { 2144 + return nil 2145 + } 2146 + out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 2147 + for i, p := range comparison.FormatPatch { 2148 + nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 2149 + out[i].Diff = &nd 2150 + cid := p.ChangeIdOrEmpty() 2151 + if cid == "" { 2152 + continue 2153 + } 2154 + out[i].Opts = types.DiffOpts{ 2155 + Split: stackSplits[cid] == "split", 2156 + RefreshUrl: refreshUrl, 2157 + Target: fmt.Sprintf("#stack-diff-%s", cid), 2158 + Field: fmt.Sprintf("stackSplit[%s]", cid), 2159 + } 2160 + } 2161 + return out 2162 + } 2163 + 2164 + func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 2165 + if comparison == nil { 2166 + return nil 2167 + } 2168 + raw := comparison.CombinedPatchRaw 2169 + if raw == "" { 2170 + raw = comparison.FormatPatchRaw 2171 + } 2172 + d := patchutil.AsNiceDiff(raw, targetBranch) 2173 + return &d 2174 + } 2175 + 2176 + func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 2177 + l := s.logger.With("handler", "ResubmitPull") 2178 + 2179 + user := s.oauth.GetMultiAccountUser(r) 2180 + if user != nil { 2181 + l = l.With("user", user.Did) 2182 + } 2183 + 2184 + pull, ok := r.Context().Value("pull").(*models.Pull) 2185 + if !ok { 2186 + l.Error("failed to get pull") 2187 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2188 + return 2189 + } 2190 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2191 + 2192 + switch r.Method { 2193 + case http.MethodGet: 2194 + s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 2195 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 2196 + Pull: pull, 2197 + }) 2198 + return 2199 + case http.MethodPost: 2200 + if pull.IsPatchBased() { 2201 + s.resubmitPatch(w, r) 2202 + return 2203 + } else if pull.IsBranchBased() { 2204 + s.resubmitBranch(w, r) 2205 + return 2206 + } else if pull.IsForkBased() { 2207 + s.resubmitFork(w, r) 2208 + return 2209 + } 2210 + } 2211 + } 2212 + 2213 + func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 2214 + l := s.logger.With("handler", "resubmitPatch") 2215 + 2216 + user := s.oauth.GetMultiAccountUser(r) 2217 + if user != nil { 2218 + l = l.With("user", user.Did) 2219 + } 2220 + 2221 + pull, ok := r.Context().Value("pull").(*models.Pull) 2222 + if !ok { 2223 + l.Error("failed to get pull") 2224 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2225 + return 2226 + } 2227 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2228 + 2229 + if user == nil || user.Did != pull.OwnerDid { 2230 + l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 2231 + w.WriteHeader(http.StatusUnauthorized) 2232 + return 2233 + } 2234 + 2235 + f, err := s.repoResolver.Resolve(r) 2236 + if err != nil { 2237 + l.Error("failed to get repo and knot", "err", err) 2238 + return 2239 + } 2240 + 2241 + patch := r.FormValue("patch") 2242 + 2243 + s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "") 2244 + } 2245 + 2246 + func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 2247 + l := s.logger.With("handler", "resubmitBranch") 2248 + 2249 + user := s.oauth.GetMultiAccountUser(r) 2250 + if user != nil { 2251 + l = l.With("user", user.Did) 2252 + } 2253 + 2254 + pull, ok := r.Context().Value("pull").(*models.Pull) 2255 + if !ok { 2256 + l.Error("failed to get pull") 2257 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 2258 + return 2259 + } 2260 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 2261 + 2262 + if user == nil || user.Did != pull.OwnerDid { 2263 + l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 2264 + w.WriteHeader(http.StatusUnauthorized) 2265 + return 2266 + } 2267 + 2268 + f, err := s.repoResolver.Resolve(r) 2269 + if err != nil { 2270 + l.Error("failed to get repo and knot", "err", err) 2271 + return 2272 + } 2273 + 2274 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 2275 + if !roles.IsPushAllowed() { 2276 + l.Warn("unauthorized user - no push permission") 2277 + w.WriteHeader(http.StatusUnauthorized) 2278 + return 2279 + } 2280 + 2281 + xrpcc := s.knotClient(f.Knot) 2282 + 2283 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch) 2284 + if err != nil { 2285 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2286 + l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err, "source_branch", pull.PullSource.Branch) 2287 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2288 + return 2289 + } 2290 + l.Error("compare request failed", "err", err, "source_branch", pull.PullSource.Branch) 2291 + s.pages.Notice(w, "resubmit-error", err.Error()) 2292 + return 2293 + } 2294 + 2295 + var comparison types.RepoFormatPatchResponse 2296 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 2297 + l.Error("failed to decode XRPC compare response", "err", err) 2298 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2299 + return 2300 + } 2301 + 2302 + sourceRev := comparison.Rev2 2303 + patch := comparison.FormatPatchRaw 2304 + combined := comparison.CombinedPatchRaw 2305 + 2306 + s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 2307 + } 2308 + 2309 + func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 2310 + l := s.logger.With("handler", "resubmitFork") 2311 + 2312 + user := s.oauth.GetMultiAccountUser(r) 2313 + if user != nil { 2314 + l = l.With("user", user.Did) 2315 + } 2316 + 2317 + pull, ok := r.Context().Value("pull").(*models.Pull) 2318 + if !ok { 2319 + l.Error("failed to get pull") 2320 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 2321 + return 2322 + } 2323 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 2324 + 2325 + if user == nil || user.Did != pull.OwnerDid { 2326 + l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 2327 + w.WriteHeader(http.StatusUnauthorized) 2328 + return 2329 + } 2330 + 2331 + f, err := s.repoResolver.Resolve(r) 2332 + if err != nil { 2333 + l.Error("failed to get repo and knot", "err", err) 2334 + return 2335 + } 2336 + 2337 + forkRepo, err := db.GetRepoByDid(s.db, string(*pull.PullSource.RepoDid)) 2338 + if err != nil { 2339 + l.Error("failed to get source repo", "err", err, "repo_did", pull.PullSource.RepoDid.String()) 2340 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2341 + return 2342 + } 2343 + 2344 + // update the hidden tracking branch to latest 2345 + client, err := s.oauth.ServiceClient( 2346 + r, 2347 + oauth.WithService(forkRepo.Knot), 2348 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 2349 + oauth.WithDev(s.config.Core.Dev), 2350 + ) 2351 + if err != nil { 2352 + l.Error("failed to connect to knot server", "err", err, "fork_knot", forkRepo.Knot) 2353 + return 2354 + } 2355 + 2356 + resp, err := tangled.RepoHiddenRef( 2357 + r.Context(), 2358 + client, 2359 + &tangled.RepoHiddenRef_Input{ 2360 + ForkRef: pull.PullSource.Branch, 2361 + RemoteRef: pull.TargetBranch, 2362 + Repo: forkRepo.RepoAt().String(), 2363 + }, 2364 + ) 2365 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2366 + s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 2367 + s.pages.Notice(w, "resubmit-error", xrpcerr.Error()) 2368 + return 2369 + } 2370 + if !resp.Success { 2371 + l.Error("failed to update tracking ref", "err", resp.Error, "fork_ref", pull.PullSource.Branch, "remote_ref", pull.TargetBranch) 2372 + s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 2373 + return 2374 + } 2375 + 2376 + hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 2377 + // extract patch by performing compare 2378 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 2379 + if err != nil { 2380 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2381 + l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 2382 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2383 + return 2384 + } 2385 + l.Error("failed to compare branches", "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 2386 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2387 + return 2388 + } 2389 + 2390 + var forkComparison types.RepoFormatPatchResponse 2391 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 2392 + l.Error("failed to decode XRPC compare response for fork", "err", err) 2393 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2394 + return 2395 + } 2396 + 2397 + // Use the fork comparison we already made 2398 + comparison := forkComparison 2399 + 2400 + sourceRev := comparison.Rev2 2401 + patch := comparison.FormatPatchRaw 2402 + combined := comparison.CombinedPatchRaw 2403 + 2404 + s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 2405 + } 2406 + 2407 + func (s *Pulls) resubmitPullHelper( 2408 + w http.ResponseWriter, 2409 + r *http.Request, 2410 + repo *models.Repo, 2411 + userDid syntax.DID, 2412 + pull *models.Pull, 2413 + patch string, 2414 + combined string, 2415 + sourceRev string, 2416 + ) { 2417 + l := s.logger.With("handler", "resubmitPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2418 + 2419 + stack := r.Context().Value("stack").(models.Stack) 2420 + if stack != nil && len(stack) != 1 { 2421 + l.Info("resubmitting stacked PR", "stack_size", len(stack)) 2422 + s.resubmitStackedPullHelper(w, r, repo, userDid, pull, patch) 2423 + return 2424 + } 2425 + 2426 + if err := s.validator.ValidatePatch(&patch); err != nil { 2427 + s.pages.Notice(w, "resubmit-error", err.Error()) 2428 + return 2429 + } 2430 + 2431 + if patch == pull.LatestPatch() { 2432 + s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 2433 + return 2434 + } 2435 + 2436 + // validate sourceRev if branch/fork based 2437 + if pull.IsBranchBased() || pull.IsForkBased() { 2438 + if sourceRev == pull.LatestSha() { 2439 + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 2440 + return 2441 + } 2442 + } 2443 + 2444 + pullAt := pull.AtUri() 2445 + newRoundNumber := len(pull.Submissions) 2446 + newPatch := patch 2447 + newSourceRev := sourceRev 2448 + combinedPatch := combined 2449 + 2450 + client, err := s.oauth.AuthorizedClient(r) 2451 + if err != nil { 2452 + l.Error("failed to authorize client", "err", err) 2453 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2454 + return 2455 + } 2456 + 2457 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, userDid.String(), pull.Rkey) 2458 + if err != nil { 2459 + // failed to get record 2460 + l.Error("failed to get record from PDS", "err", err, "rkey", pull.Rkey) 2461 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 2462 + return 2463 + } 2464 + 2465 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2466 + if err != nil { 2467 + l.Error("failed to upload patch blob", "err", err) 2468 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2469 + return 2470 + } 2471 + record := pull.AsRecord() 2472 + record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 2473 + CreatedAt: time.Now().Format(time.RFC3339), 2474 + PatchBlob: blob.Blob, 2475 + }) 2476 + 2477 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 2478 + Collection: tangled.RepoPullNSID, 2479 + Repo: userDid.String(), 2480 + Rkey: pull.Rkey, 2481 + SwapRecord: ex.Cid, 2482 + Record: &lexutil.LexiconTypeDecoder{ 2483 + Val: &record, 2484 + }, 2485 + }) 2486 + if err != nil { 2487 + l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey) 2488 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2489 + return 2490 + } 2491 + 2492 + err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 2493 + if err != nil { 2494 + l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber) 2495 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2496 + return 2497 + } 2498 + 2499 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2500 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2501 + } 2502 + 2503 + func (s *Pulls) resubmitStackedPullHelper( 2504 + w http.ResponseWriter, 2505 + r *http.Request, 2506 + repo *models.Repo, 2507 + userDid syntax.DID, 2508 + pull *models.Pull, 2509 + patch string, 2510 + ) { 2511 + l := s.logger.With("handler", "resubmitStackedPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2512 + 2513 + targetBranch := pull.TargetBranch 2514 + 2515 + origStack, _ := r.Context().Value("stack").(models.Stack) 2516 + 2517 + formatPatches, err := patchutil.ExtractPatches(patch) 2518 + if err != nil { 2519 + l.Error("failed to extract patches", "err", err) 2520 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.") 2521 + return 2522 + } 2523 + 2524 + // must have atleast 1 patch to begin with 2525 + if len(formatPatches) == 0 { 2526 + l.Error("no patches found in the generated format-patch") 2527 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.") 2528 + return 2529 + } 2530 + 2531 + client, err := s.oauth.AuthorizedClient(r) 2532 + if err != nil { 2533 + l.Error("failed to get authorized client", "err", err) 2534 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2535 + return 2536 + } 2537 + 2538 + // first upload all blobs 2539 + blobs := make([]*lexutil.LexBlob, len(formatPatches)) 2540 + for i, p := range formatPatches { 2541 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 2542 + if err != nil { 2543 + l.Error("failed to upload patch blob", "err", err, "patch_index", i) 2544 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2545 + return 2546 + } 2547 + l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 2548 + blobs[i] = blob.Blob 2549 + } 2550 + 2551 + newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 2552 + if err != nil { 2553 + l.Error("failed to create resubmitted stack", "err", err) 2554 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2555 + return 2556 + } 2557 + 2558 + // find the diff between the stacks, first, map them by changeId 2559 + origById := make(map[string]*models.Pull) 2560 + newById := make(map[string]*models.Pull) 2561 + for _, p := range origStack { 2562 + origById[p.LatestSubmission().ChangeId()] = p 2563 + } 2564 + for _, p := range newStack { 2565 + newById[p.LatestSubmission().ChangeId()] = p 2566 + } 2567 + 2568 + // commits that got deleted: corresponding pull is closed 2569 + // commits that got added: new pull is created 2570 + // commits that got updated: corresponding pull is resubmitted & new round begins 2571 + additions := make(map[string]*models.Pull) 2572 + deletions := make(map[string]*models.Pull) 2573 + updated := make(map[string]struct{}) 2574 + 2575 + // pulls in original stack but not in new one 2576 + for _, op := range origStack { 2577 + if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok { 2578 + deletions[op.LatestSubmission().ChangeId()] = op 2579 + } 2580 + } 2581 + 2582 + // pulls in new stack but not in original one 2583 + for _, np := range newStack { 2584 + if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok { 2585 + additions[np.LatestSubmission().ChangeId()] = np 2586 + } 2587 + } 2588 + 2589 + // NOTE: this loop can be written in any of above blocks, 2590 + // but is written separately in the interest of simpler code 2591 + for _, np := range newStack { 2592 + if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2593 + // pull exists in both stacks 2594 + updated[op.LatestSubmission().ChangeId()] = struct{}{} 2595 + } 2596 + } 2597 + 2598 + // NOTE: we can go through the newStack and update dependent relations and 2599 + // rkeys now that we know which ones have been updated 2600 + // update dependentOn relations for the entire stack 2601 + var parentAt *syntax.ATURI 2602 + for _, np := range newStack { 2603 + if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2604 + // pull exists in both stacks 2605 + np.Rkey = op.Rkey 2606 + } 2607 + np.DependentOn = parentAt 2608 + x := np.AtUri() 2609 + parentAt = &x 2610 + } 2611 + 2612 + l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated)) 2613 + 2614 + tx, err := s.db.Begin() 2615 + if err != nil { 2616 + l.Error("failed to start transaction", "err", err) 2617 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2618 + return 2619 + } 2620 + defer tx.Rollback() 2621 + 2622 + // pds updates to make 2623 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 2624 + 2625 + // deleted pulls are marked as deleted in the DB 2626 + for _, p := range deletions { 2627 + // do not do delete already merged PRs 2628 + if p.State == models.PullMerged { 2629 + continue 2630 + } 2631 + 2632 + err := db.AbandonPulls(tx, orm.FilterEq("repo_did", string(p.RepoDid)), orm.FilterEq("at_uri", p.AtUri())) 2633 + if err != nil { 2634 + l.Error("failed to delete pull", "err", err, "pull_id", p.PullId) 2635 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2636 + return 2637 + } 2638 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2639 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 2640 + Collection: tangled.RepoPullNSID, 2641 + Rkey: p.Rkey, 2642 + }, 2643 + }) 2644 + } 2645 + 2646 + // new pulls are created 2647 + for _, p := range additions { 2648 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 2649 + if err != nil { 2650 + l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId()) 2651 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2652 + return 2653 + } 2654 + p.Submissions[0].Blob = *blob.Blob 2655 + 2656 + if err = db.PutPull(tx, p); err != nil { 2657 + l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId()) 2658 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2659 + return 2660 + } 2661 + 2662 + record := p.AsRecord() 2663 + record.Rounds = []*tangled.RepoPull_Round{ 2664 + { 2665 + CreatedAt: time.Now().Format(time.RFC3339), 2666 + PatchBlob: blob.Blob, 2667 + }, 2668 + } 2669 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2670 + RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2671 + Collection: tangled.RepoPullNSID, 2672 + Rkey: &p.Rkey, 2673 + Value: &lexutil.LexiconTypeDecoder{ 2674 + Val: &record, 2675 + }, 2676 + }, 2677 + }) 2678 + } 2679 + 2680 + // updated pulls are, well, updated; to start a new round 2681 + for id := range updated { 2682 + op, _ := origById[id] 2683 + np, _ := newById[id] 2684 + 2685 + // do not update already merged PRs 2686 + if op.State == models.PullMerged { 2687 + continue 2688 + } 2689 + 2690 + // resubmit the new pull 2691 + np.Rkey = op.Rkey 2692 + pullAt := op.AtUri() 2693 + newRoundNumber := len(op.Submissions) 2694 + newPatch := np.LatestPatch() 2695 + combinedPatch := np.LatestSubmission().Combined 2696 + newSourceRev := np.LatestSha() 2697 + 2698 + blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip) 2699 + if err != nil { 2700 + l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId) 2701 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2702 + return 2703 + } 2704 + 2705 + // create new round 2706 + err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 2707 + if err != nil { 2708 + l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 2709 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2710 + return 2711 + } 2712 + 2713 + // update dependent-on relation 2714 + if np.DependentOn != nil { 2715 + err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri())) 2716 + if err != nil { 2717 + l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 2718 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2719 + return 2720 + } 2721 + } 2722 + 2723 + record := np.AsRecord() 2724 + record.Rounds = op.AsRecord().Rounds 2725 + record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 2726 + CreatedAt: time.Now().Format(time.RFC3339), 2727 + PatchBlob: blob.Blob, 2728 + }) 2729 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2730 + RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2731 + Collection: tangled.RepoPullNSID, 2732 + Rkey: op.Rkey, 2733 + Value: &lexutil.LexiconTypeDecoder{ 2734 + Val: &record, 2735 + }, 2736 + }, 2737 + }) 2738 + } 2739 + 2740 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2741 + Repo: userDid.String(), 2742 + Writes: writes, 2743 + }) 2744 + if err != nil { 2745 + l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes)) 2746 + s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2747 + return 2748 + } 2749 + 2750 + err = tx.Commit() 2751 + if err != nil { 2752 + l.Error("failed to commit resubmit transaction", "err", err) 2753 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2754 + return 2755 + } 2756 + 2757 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2758 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2759 + } 2760 + 2761 + func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2762 + l := s.logger.With("handler", "MergePull") 2763 + 2764 + user := s.oauth.GetMultiAccountUser(r) 2765 + if user != nil { 2766 + l = l.With("user", user.Did) 2767 + } 2768 + 2769 + f, err := s.repoResolver.Resolve(r) 2770 + if err != nil { 2771 + l.Error("failed to resolve repo", "err", err) 2772 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2773 + return 2774 + } 2775 + l = l.With("repo_at", f.RepoAt().String()) 2776 + 2777 + pull, ok := r.Context().Value("pull").(*models.Pull) 2778 + if !ok { 2779 + l.Error("failed to get pull") 2780 + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2781 + return 2782 + } 2783 + l = l.With("pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2784 + 2785 + stack, ok := r.Context().Value("stack").(models.Stack) 2786 + if !ok { 2787 + l.Error("failed to get stack") 2788 + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2789 + return 2790 + } 2791 + 2792 + // combine patches of substack 2793 + subStack := stack.Below(pull) 2794 + // collect the portion of the stack that is mergeable 2795 + pullsToMerge := subStack.Mergeable() 2796 + l = l.With("pulls_to_merge", len(pullsToMerge)) 2797 + 2798 + patch := pullsToMerge.CombinedPatch() 2799 + 2800 + ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2801 + if err != nil { 2802 + l.Error("failed to resolve identity", "err", err, "owner_did", pull.OwnerDid) 2803 + w.WriteHeader(http.StatusNotFound) 2804 + return 2805 + } 2806 + 2807 + email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2808 + if err != nil { 2809 + l.Warn("failed to get primary email", "err", err, "owner_did", pull.OwnerDid) 2810 + } 2811 + 2812 + authorName := ident.Handle.String() 2813 + mergeInput := &tangled.RepoMerge_Input{ 2814 + Did: f.Did, 2815 + Name: f.Name, 2816 + Branch: pull.TargetBranch, 2817 + Patch: patch, 2818 + CommitMessage: &pull.Title, 2819 + AuthorName: &authorName, 2820 + } 2821 + 2822 + if pull.Body != "" { 2823 + mergeInput.CommitBody = &pull.Body 2824 + } 2825 + 2826 + if email.Address != "" { 2827 + mergeInput.AuthorEmail = &email.Address 2828 + } 2829 + 2830 + client, err := s.oauth.ServiceClient( 2831 + r, 2832 + oauth.WithService(f.Knot), 2833 + oauth.WithLxm(tangled.RepoMergeNSID), 2834 + oauth.WithDev(s.config.Core.Dev), 2835 + oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky 2836 + ) 2837 + if err != nil { 2838 + l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 2839 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2840 + return 2841 + } 2842 + 2843 + err = tangled.RepoMerge(r.Context(), client, mergeInput) 2844 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2845 + s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err) 2846 + s.pages.Notice(w, "pull-merge-error", xrpcerr.Error()) 2847 + return 2848 + } 2849 + 2850 + tx, err := s.db.Begin() 2851 + if err != nil { 2852 + l.Error("failed to start transaction", "err", err) 2853 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2854 + return 2855 + } 2856 + defer tx.Rollback() 2857 + 2858 + var atUris []syntax.ATURI 2859 + for _, p := range pullsToMerge { 2860 + atUris = append(atUris, p.AtUri()) 2861 + p.State = models.PullMerged 2862 + } 2863 + err = db.MergePulls(tx, orm.FilterEq("repo_did", string(f.RepoDid)), orm.FilterIn("at_uri", atUris)) 2864 + if err != nil { 2865 + l.Error("failed to update pull request status in database", "err", err) 2866 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2867 + return 2868 + } 2869 + 2870 + err = tx.Commit() 2871 + if err != nil { 2872 + // TODO: this is unsound, we should also revert the merge from the knotserver here 2873 + l.Error("failed to commit merge transaction", "err", err) 2874 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2875 + return 2876 + } 2877 + 2878 + // notify about the pull merge 2879 + for _, p := range pullsToMerge { 2880 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2881 + } 2882 + 2883 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2884 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2885 + } 2886 + 2887 + func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2888 + l := s.logger.With("handler", "ClosePull") 2889 + 2890 + user := s.oauth.GetMultiAccountUser(r) 2891 + if user != nil { 2892 + l = l.With("user", user.Did) 2893 + } 2894 + 2895 + f, err := s.repoResolver.Resolve(r) 2896 + if err != nil { 2897 + l.Error("failed to resolve repo", "err", err) 2898 + return 2899 + } 2900 + 2901 + pull, ok := r.Context().Value("pull").(*models.Pull) 2902 + if !ok { 2903 + l.Error("failed to get pull") 2904 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2905 + return 2906 + } 2907 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2908 + 2909 + // auth filter: only owner or collaborators can close 2910 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 2911 + isOwner := roles.IsOwner() 2912 + isCollaborator := roles.IsCollaborator() 2913 + isPullAuthor := user.Did == pull.OwnerDid 2914 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2915 + if !isCloseAllowed { 2916 + l.Error("unauthorized to close pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 2917 + s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2918 + return 2919 + } 2920 + 2921 + // Start a transaction 2922 + tx, err := s.db.BeginTx(r.Context(), nil) 2923 + if err != nil { 2924 + l.Error("failed to start transaction", "err", err) 2925 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 2926 + return 2927 + } 2928 + defer tx.Rollback() 2929 + 2930 + // if this PR is stacked, then we want to close all PRs above this one on the stack 2931 + stack := r.Context().Value("stack").(models.Stack) 2932 + pullsToClose := stack.Above(pull) 2933 + var atUris []syntax.ATURI 2934 + for _, p := range pullsToClose { 2935 + atUris = append(atUris, p.AtUri()) 2936 + p.State = models.PullClosed 2937 + } 2938 + err = db.ClosePulls( 2939 + tx, 2940 + orm.FilterEq("repo_did", string(f.RepoDid)), 2941 + orm.FilterIn("at_uri", atUris), 2942 + ) 2943 + if err != nil { 2944 + l.Error("failed to close pulls in database", "err", err, "pulls_to_close", len(pullsToClose)) 2945 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 2946 + } 2947 + 2948 + // Commit the transaction 2949 + if err = tx.Commit(); err != nil { 2950 + l.Error("failed to commit transaction", "err", err) 2951 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 2952 + return 2953 + } 2954 + 2955 + for _, p := range pullsToClose { 2956 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2957 + } 2958 + 2959 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2960 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2961 + } 2962 + 2963 + func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2964 + l := s.logger.With("handler", "ReopenPull") 2965 + 2966 + user := s.oauth.GetMultiAccountUser(r) 2967 + if user != nil { 2968 + l = l.With("user", user.Did) 2969 + } 2970 + 2971 + f, err := s.repoResolver.Resolve(r) 2972 + if err != nil { 2973 + l.Error("failed to resolve repo", "err", err) 2974 + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2975 + return 2976 + } 2977 + 2978 + pull, ok := r.Context().Value("pull").(*models.Pull) 2979 + if !ok { 2980 + l.Error("failed to get pull") 2981 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2982 + return 2983 + } 2984 + l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State) 2985 + 2986 + // auth filter: only owner or collaborators can close 2987 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 2988 + isOwner := roles.IsOwner() 2989 + isCollaborator := roles.IsCollaborator() 2990 + isPullAuthor := user.Did == pull.OwnerDid 2991 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2992 + if !isCloseAllowed { 2993 + l.Error("unauthorized to reopen pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 2994 + s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2995 + return 2996 + } 2997 + 2998 + // Start a transaction 2999 + tx, err := s.db.BeginTx(r.Context(), nil) 3000 + if err != nil { 3001 + l.Error("failed to start transaction", "err", err) 3002 + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 3003 + return 3004 + } 3005 + defer tx.Rollback() 3006 + 3007 + // if this PR is stacked, then we want to reopen all PRs above this one on the stack 3008 + stack := r.Context().Value("stack").(models.Stack) 3009 + pullsToReopen := stack.Below(pull) 3010 + var atUris []syntax.ATURI 3011 + for _, p := range pullsToReopen { 3012 + atUris = append(atUris, p.AtUri()) 3013 + p.State = models.PullOpen 3014 + } 3015 + err = db.ReopenPulls( 3016 + tx, 3017 + orm.FilterEq("repo_did", string(f.RepoDid)), 3018 + orm.FilterIn("at_uri", atUris), 3019 + ) 3020 + if err != nil { 3021 + l.Error("failed to reopen pulls in database", "err", err, "pulls_to_reopen", len(pullsToReopen)) 3022 + s.pages.Notice(w, "pull-close", "Failed to reopen pull.") 3023 + } 3024 + 3025 + // Commit the transaction 3026 + if err = tx.Commit(); err != nil { 3027 + l.Error("failed to commit transaction", "err", err) 3028 + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 3029 + return 3030 + } 3031 + 3032 + for _, p := range pullsToReopen { 3033 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 3034 + } 3035 + 3036 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 3037 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 3038 + } 3039 + 3040 + func (s *Pulls) newStack( 3041 + ctx context.Context, 3042 + repo *models.Repo, 3043 + userDid syntax.DID, 3044 + targetBranch string, 3045 + pullSource *models.PullSource, 3046 + formatPatches []types.FormatPatch, 3047 + blobs []*lexutil.LexBlob, 3048 + stackTitles, stackBodies map[string]string, 3049 + ) (models.Stack, error) { 3050 + var stack models.Stack 3051 + var parentAtUri *syntax.ATURI 3052 + for i, fp := range formatPatches { 3053 + // all patches must have a jj change-id 3054 + cid, err := fp.ChangeId() 3055 + if err != nil { 3056 + return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 3057 + } 3058 + 3059 + title := fp.Title 3060 + body := fp.Body 3061 + if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" { 3062 + title = override 3063 + } 3064 + if override, ok := stackBodies[cid]; ok { 3065 + body = override 3066 + } 3067 + rkey := tid.TID() 3068 + 3069 + mentions, references := s.mentionsResolver.Resolve(ctx, body) 3070 + 3071 + now := time.Now() 3072 + 3073 + pull := models.Pull{ 3074 + Title: title, 3075 + Body: body, 3076 + TargetBranch: targetBranch, 3077 + OwnerDid: userDid.String(), 3078 + RepoDid: syntax.DID(repo.RepoDid), 3079 + Rkey: rkey, 3080 + Mentions: mentions, 3081 + References: references, 3082 + Submissions: []*models.PullSubmission{ 3083 + { 3084 + Patch: fp.Raw, 3085 + SourceRev: fp.SHA, 3086 + Combined: fp.Raw, 3087 + Blob: *blobs[i], 3088 + Created: now, 3089 + }, 3090 + }, 3091 + PullSource: pullSource, 3092 + Created: now, 3093 + State: models.PullOpen, 3094 + 3095 + DependentOn: parentAtUri, 3096 + Repo: repo, 3097 + } 3098 + 3099 + stack = append(stack, &pull) 3100 + 3101 + parent := pull.AtUri() 3102 + parentAtUri = &parent 3103 + } 3104 + 3105 + return stack, nil 3106 + } 3107 + 84 3108 func gz(s string) io.Reader { 85 3109 var b bytes.Buffer 86 3110 w := gzip.NewWriter(&b) ··· 91 3115 92 3116 func ptrPullState(s models.PullState) *models.PullState { return &s } 93 3117 3118 + func repoPullTarget(repo *models.Repo, branch string) *tangled.RepoPull_Target { 3119 + return &tangled.RepoPull_Target{ 3120 + Branch: branch, 3121 + Repo: repo.RepoDid, 3122 + } 3123 + } 3124 + 94 3125 95 3126 96 3127 ··· 238 3269 s.db, 239 3270 len(shas), 240 3271 orm.FilterEq("p.repo_owner", f.Did), 241 - orm.FilterEq("p.repo_name", f.Rkey), 3272 + orm.FilterEq("p.repo_name", f.Name), 242 3273 orm.FilterEq("p.knot", f.Knot), 243 3274 orm.FilterIn("p.sha", shas), 244 3275 ) ··· 450 3481 return pages.Unknown 451 3482 } 452 3483 453 - var sourceRepoDid string 454 - if pull.PullSource.RepoDid != nil { 455 - sourceRepoDid = string(*pull.PullSource.RepoDid) 3484 + var sourceRepo syntax.ATURI 3485 + if pull.PullSource.RepoAt != nil { 3486 + sourceRepo = *pull.PullSource.RepoAt 456 3487 } else { 457 - sourceRepoDid = repo.RepoDid 3488 + sourceRepo = repo.RepoAt() 458 3489 } 459 3490 460 3491 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 461 - branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid) 3492 + branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepo.String()) 462 3493 if err != nil { 463 3494 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 464 3495 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) ··· 598 3629 searchOpts := models.PullSearchOptions{ 599 3630 Keywords: tf.Keywords, 600 3631 Phrases: tf.Phrases, 601 - RepoDid: f.RepoDid, 3632 + RepoAt: f.RepoAt().String(), 602 3633 State: state, 603 3634 AuthorDid: authorDid, 604 3635 Labels: labels, ··· 670 3701 } 671 3702 } else { 672 3703 filters := []orm.Filter{ 673 - orm.FilterEq("repo_did", f.RepoDid), 3704 + orm.FilterEq("repo_at", f.RepoAt()), 674 3705 } 675 3706 if state != nil { 676 3707 filters = append(filters, orm.FilterEq("state", *state)) ··· 690 3721 for _, p := range pulls { 691 3722 var pullSourceRepo *models.Repo 692 3723 if p.PullSource != nil { 693 - if p.PullSource.RepoDid != nil { 694 - pullSourceRepo, err = db.GetRepoByDid(s.db, string(*p.PullSource.RepoDid)) 3724 + if p.PullSource.RepoAt != nil { 3725 + pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 695 3726 if err != nil { 696 - l.Error("failed to get repo by did", "err", err, "repo_did", p.PullSource.RepoDid.String()) 3727 + l.Error("failed to get repo by at uri", "err", err, "repo_at", p.PullSource.RepoAt.String()) 697 3728 continue 698 3729 } else { 699 3730 p.PullSource.Repo = pullSourceRepo ··· 760 3791 s.db, 761 3792 len(shas), 762 3793 orm.FilterEq("p.repo_owner", f.Did), 763 - orm.FilterEq("p.repo_name", f.Rkey), 3794 + orm.FilterEq("p.repo_name", f.Name), 764 3795 orm.FilterEq("p.knot", f.Knot), 765 3796 orm.FilterIn("p.sha", shas), 766 3797 ) ··· 909 3940 910 3941 comment := &models.PullComment{ 911 3942 OwnerDid: user.Did, 912 - RepoDid: string(f.RepoDid), 3943 + RepoAt: f.RepoAt().String(), 913 3944 PullId: pull.PullId, 914 3945 Body: body, 915 3946 CommentAt: atResp.Uri, ··· 1157 4188 1158 4189 repoString := strings.SplitN(forkRepo, "/", 2) 1159 4190 forkOwnerDid := repoString[0] 1160 - forkRkey := strings.ToLower(repoString[1]) 1161 - fork, err := db.GetForkByDid(s.db, forkOwnerDid, forkRkey) 4191 + repoName := repoString[1] 4192 + fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 1162 4193 if errors.Is(err, sql.ErrNoRows) { 1163 4194 s.pages.Notice(w, "pull", "No such fork.") 1164 4195 return 1165 4196 } else if err != nil { 1166 - l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "fork_rkey", forkRkey) 4197 + l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "repo_name", repoName) 1167 4198 s.pages.Notice(w, "pull", "Failed to fetch fork.") 1168 4199 return 1169 4200 } ··· 1241 4272 return 1242 4273 } 1243 4274 1244 - forkDid := syntax.DID(fork.RepoDid) 4275 + forkAtUri := fork.RepoAt() 4276 + var forkDid *syntax.DID 4277 + if fork.RepoDid != "" { 4278 + forkDid = new(syntax.DID) 4279 + *forkDid = syntax.DID(fork.RepoDid) 4280 + } 4281 + 1245 4282 pullSource := &models.PullSource{ 1246 4283 Branch: sourceBranch, 1247 - RepoDid: &forkDid, 4284 + RepoAt: &forkAtUri, 4285 + RepoDid: forkDid, 1248 4286 } 1249 4287 1250 4288 s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) ··· 1336 4374 Body: body, 1337 4375 TargetBranch: targetBranch, 1338 4376 OwnerDid: userDid.String(), 1339 - RepoDid: syntax.DID(repo.RepoDid), 4377 + RepoAt: repo.RepoAt(), 1340 4378 Rkey: rkey, 1341 4379 Mentions: mentions, 1342 4380 References: references, ··· 1375 4413 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1376 4414 return 1377 4415 } 1378 - pullId, err := db.NextPullId(tx, repo.RepoDid) 4416 + pullId, err := db.NextPullId(tx, repo.RepoAt()) 1379 4417 if err != nil { 1380 4418 s.logger.Error("failed to get pull id", "err", err) 1381 4419 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 2334 5372 return 2335 5373 } 2336 5374 2337 - forkRepo, err := db.GetRepoByDid(s.db, string(*pull.PullSource.RepoDid)) 5375 + forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 2338 5376 if err != nil { 2339 - l.Error("failed to get source repo", "err", err, "repo_did", pull.PullSource.RepoDid.String()) 5377 + l.Error("failed to get source repo", "err", err, "repo_at", pull.PullSource.RepoAt.String()) 2340 5378 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2341 5379 return 2342 5380 } ··· 2629 5667 continue 2630 5668 } 2631 5669 2632 - err := db.AbandonPulls(tx, orm.FilterEq("repo_did", string(p.RepoDid)), orm.FilterEq("at_uri", p.AtUri())) 5670 + err := db.AbandonPulls(tx, orm.FilterEq("repo_at", p.RepoAt), orm.FilterEq("at_uri", p.AtUri())) 2633 5671 if err != nil { 2634 5672 l.Error("failed to delete pull", "err", err, "pull_id", p.PullId) 2635 5673 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") ··· 2860 5898 atUris = append(atUris, p.AtUri()) 2861 5899 p.State = models.PullMerged 2862 5900 } 2863 - err = db.MergePulls(tx, orm.FilterEq("repo_did", string(f.RepoDid)), orm.FilterIn("at_uri", atUris)) 5901 + err = db.MergePulls(tx, orm.FilterEq("repo_at", f.RepoAt()), orm.FilterIn("at_uri", atUris)) 2864 5902 if err != nil { 2865 5903 l.Error("failed to update pull request status in database", "err", err) 2866 5904 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2937 5975 } 2938 5976 err = db.ClosePulls( 2939 5977 tx, 2940 - orm.FilterEq("repo_did", string(f.RepoDid)), 5978 + orm.FilterEq("repo_at", f.RepoAt()), 2941 5979 orm.FilterIn("at_uri", atUris), 2942 5980 ) 2943 5981 if err != nil { ··· 3014 6052 } 3015 6053 err = db.ReopenPulls( 3016 6054 tx, 3017 - orm.FilterEq("repo_did", string(f.RepoDid)), 6055 + orm.FilterEq("repo_at", f.RepoAt()), 3018 6056 orm.FilterIn("at_uri", atUris), 3019 6057 ) 3020 6058 if err != nil { ··· 3075 6113 Body: body, 3076 6114 TargetBranch: targetBranch, 3077 6115 OwnerDid: userDid.String(), 3078 - RepoDid: syntax.DID(repo.RepoDid), 6116 + RepoAt: repo.RepoAt(), 3079 6117 Rkey: rkey, 3080 6118 Mentions: mentions, 3081 6119 References: references, ··· 3114 6152 } 3115 6153 3116 6154 func ptrPullState(s models.PullState) *models.PullState { return &s } 3117 - 3118 - func repoPullTarget(repo *models.Repo, branch string) *tangled.RepoPull_Target { 3119 - return &tangled.RepoPull_Target{ 3120 - Branch: branch, 3121 - Repo: repo.RepoDid, 3122 - } 3123 - }
appview/repo/artifact.go

This file has not been changed.

appview/repo/feed.go

This file has not been changed.

appview/repo/index.go

This patch was likely rebased, as context lines do not match.

appview/repo/opengraph.go

This file has not been changed.

appview/repo/repo.go

This file has not been changed.

appview/repo/repo_util.go

This file has not been changed.

appview/repo/router.go

This file has not been changed.

appview/repo/settings.go

This file has not been changed.

appview/repo/tags.go

This file has not been changed.

appview/repo/webhooks.go

This file has not been changed.

-1
appview/reporesolver/resolver.go
··· 127 127 // this is basically a models.Repo 128 128 OwnerDid: ownerId.DID.String(), 129 129 OwnerHandle: ownerHandle, 130 - RepoDid: repo.RepoDid, 131 130 Name: repo.Name, 132 131 Rkey: repo.Rkey, 133 132 Description: repo.Description,
appview/settings/settings.go

This file has not been changed.

appview/sites/sites.go

This file has not been changed.

appview/state/knotstream.go

This file has not been changed.

appview/state/profile.go

This file has not been changed.

appview/state/router.go

This patch was likely rebased, as context lines do not match.

appview/state/state.go

This file has not been changed.

appview/strings/strings.go

This file has not been changed.

+6 -3
appview/db/notifications.go
··· 135 135 select 136 136 n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 137 137 n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 138 - r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics, 138 + r.id as r_id, r.did as r_did, r.rkey as r_rkey, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics, 139 139 i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open, 140 140 p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state 141 141 from notifications n ··· 164 164 var issue models.Issue 165 165 var pull models.Pull 166 166 var rId, iId, pId sql.NullInt64 167 - var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString 167 + var rDid, rRkey, rName, rDescription, rWebsite, rTopicStr sql.NullString 168 168 var iDid sql.NullString 169 169 var iIssueId sql.NullInt64 170 170 var iTitle sql.NullString ··· 177 177 err := rows.Scan( 178 178 &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 179 179 &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 180 - &rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr, 180 + &rId, &rDid, &rRkey, &rName, &rDescription, &rWebsite, &rTopicStr, 181 181 &iId, &iDid, &iIssueId, &iTitle, &iOpen, 182 182 &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 183 183 ) ··· 199 199 if rDid.Valid { 200 200 repo.Did = rDid.String 201 201 } 202 + if rRkey.Valid { 203 + repo.Rkey = rRkey.String 204 + } 202 205 if rName.Valid { 203 206 repo.Name = rName.String 204 207 }

History

9 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
appview: handlers, state, and templates for more record types w/ repoDID
merge conflicts detected
expand
  • api/tangled/cbor_gen.go:866
  • api/tangled/feedstar.go:5
  • api/tangled/gitrefUpdate.go:29
  • api/tangled/repocollaborator.go:19
  • api/tangled/repoissue.go:22
  • api/tangled/repopull.go:39
  • api/tangled/tangledrepo.go:24
  • cmd/cborgen/cborgen.go:17
  • knotserver/xrpc/merge.go:118
  • lexicons/feed/star.json:10
  • lexicons/git/refUpdate.json:11
  • lexicons/issue/issue.json:9
  • lexicons/pulls/pull.json:65
  • lexicons/repo/collaborator.json:11
  • lexicons/repo/repo.json:6
expand 0 comments
1 commit
expand
appview: handlers, state, and templates for more record types w/ repoDID
expand 0 comments
1 commit
expand
appview: handlers, state, and templates for more record types w/ repoDID
expand 0 comments
1 commit
expand
appview: handlers, state, and templates for more record types w/ repoDID
expand 0 comments
1 commit
expand
appview: handlers, state, and templates for more record types w/ repoDID
expand 0 comments
1 commit
expand
appview: handlers, state, and templates for more record types w/ repoDID
expand 0 comments
1 commit
expand
appview: handlers, state, and templates for more record types w/ repoDID
expand 0 comments
1 commit
expand
appview: handlers, state, and templates for more record types w/ repoDID
expand 0 comments
1 commit
expand
appview: handlers, state, and templates for more record types w/ repoDID
expand 0 comments