This repository has no description
0

Configure Feed

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

core / appview / pulls / create.go
16 kB 532 lines
1package pulls 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "net/http" 10 "strings" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/knotcompat" 16 "tangled.org/core/appview/models" 17 "tangled.org/core/appview/oauth" 18 "tangled.org/core/appview/reporesolver" 19 "tangled.org/core/patchutil" 20 "tangled.org/core/tid" 21 "tangled.org/core/types" 22 "tangled.org/core/xrpc" 23 "tangled.org/core/xrpc/xrpcclient" 24 25 comatproto "github.com/bluesky-social/indigo/api/atproto" 26 "github.com/bluesky-social/indigo/atproto/syntax" 27 lexutil "github.com/bluesky-social/indigo/lex/util" 28) 29 30func (s *Pulls) handleBranchBasedPull( 31 w http.ResponseWriter, 32 r *http.Request, 33 repo *models.Repo, 34 userDid syntax.DID, 35 title, 36 body, 37 targetBranch, 38 sourceBranch string, 39 isStacked bool, 40 stackTitles, stackBodies map[string]string, 41 stackBlobs map[string][]string, 42) { 43 l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 44 45 xrpcc := s.knotClient(repo.Knot) 46 47 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 48 if err != nil { 49 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 50 l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err) 51 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 52 return 53 } 54 l.Error("failed to compare", "err", err) 55 s.pages.Notice(w, "pull", err.Error()) 56 return 57 } 58 59 var comparison types.RepoFormatPatchResponse 60 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 61 l.Error("failed to decode XRPC compare response", "err", err) 62 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 63 return 64 } 65 66 if len(comparison.FormatPatch) == 0 { 67 s.pages.Notice(w, "pull", "No commits between target and source.") 68 return 69 } 70 71 sourceRev := comparison.Rev2 72 patch := comparison.FormatPatchRaw 73 combined := comparison.CombinedPatchRaw 74 75 if err := validatePatch(&patch); err != nil { 76 s.logger.Error("failed to validate patch", "err", err) 77 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 78 return 79 } 80 81 pullSource := &models.PullSource{ 82 Branch: sourceBranch, 83 } 84 85 s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies, stackBlobs) 86} 87 88func (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, stackBlobs map[string][]string) { 89 if err := validatePatch(&patch); err != nil { 90 s.logger.Error("patch validation failed", "err", err) 91 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 92 return 93 } 94 95 s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies, stackBlobs) 96} 97 98func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepoDid string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string, stackBlobs map[string][]string) { 99 l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo_did", forkRepoDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 100 101 if forkRepoDid == "" { 102 s.pages.Notice(w, "pull", "No such fork.") 103 return 104 } 105 fork, err := db.GetForkByRepoDid(s.db, forkRepoDid) 106 if errors.Is(err, sql.ErrNoRows) { 107 s.pages.Notice(w, "pull", "No such fork.") 108 return 109 } else if err != nil { 110 l.Error("failed to fetch fork", "err", err, "fork_repo_did", forkRepoDid) 111 s.pages.Notice(w, "pull", "Failed to fetch fork.") 112 return 113 } 114 115 client, err := s.oauth.ServiceClient( 116 r, 117 oauth.WithService(fork.Knot), 118 oauth.WithLxm(tangled.RepoHiddenRefNSID), 119 oauth.WithDev(s.config.Core.Dev), 120 ) 121 122 resp, err := tangled.RepoHiddenRef( 123 r.Context(), 124 client, 125 &tangled.RepoHiddenRef_Input{ 126 ForkRef: sourceBranch, 127 RemoteRef: targetBranch, 128 Repo: fork.RepoAt().String(), 129 }, 130 ) 131 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 132 s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 133 s.pages.Notice(w, "pull", xrpcerr.Error()) 134 return 135 } 136 137 if !resp.Success { 138 errorMsg := "Failed to create pull request" 139 if resp.Error != nil { 140 errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 141 } 142 s.pages.Notice(w, "pull", errorMsg) 143 return 144 } 145 146 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 147 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 148 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 149 // hiddenRef: hidden/feature-1/main (on repo-fork) 150 // targetBranch: main (on repo-1) 151 // sourceBranch: feature-1 (on repo-fork) 152 forkXrpcc := s.knotClient(fork.Knot) 153 154 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 155 if err != nil { 156 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 157 l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef) 158 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 159 return 160 } 161 l.Error("failed to compare across branches", "err", err, "hidden_ref", hiddenRef) 162 s.pages.Notice(w, "pull", err.Error()) 163 return 164 } 165 166 var comparison types.RepoFormatPatchResponse 167 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 168 l.Error("failed to decode XRPC compare response for fork", "err", err) 169 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 170 return 171 } 172 173 if len(comparison.FormatPatch) == 0 { 174 s.pages.Notice(w, "pull", "No commits between target and source.") 175 return 176 } 177 178 sourceRev := comparison.Rev2 179 patch := comparison.FormatPatchRaw 180 combined := comparison.CombinedPatchRaw 181 182 if err := validatePatch(&patch); err != nil { 183 s.logger.Error("failed to validate patch", "err", err) 184 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 185 return 186 } 187 188 forkDid := syntax.DID(fork.RepoDid) 189 pullSource := &models.PullSource{ 190 Branch: sourceBranch, 191 RepoDid: &forkDid, 192 } 193 194 s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies, stackBlobs) 195} 196 197func (s *Pulls) createPullRequest( 198 w http.ResponseWriter, 199 r *http.Request, 200 repo *models.Repo, 201 userDid syntax.DID, 202 title, body, targetBranch string, 203 patch string, 204 combined string, 205 sourceRev string, 206 pullSource *models.PullSource, 207 isStacked bool, 208 stackTitles, stackBodies map[string]string, 209 stackBlobs map[string][]string, 210) { 211 l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked) 212 213 if isStacked { 214 // creates a series of PRs, each linking to the previous, identified by jj's change-id 215 s.createStackedPullRequest( 216 w, 217 r, 218 repo, 219 userDid, 220 targetBranch, 221 patch, 222 sourceRev, 223 pullSource, 224 stackTitles, 225 stackBodies, 226 stackBlobs, 227 ) 228 return 229 } 230 231 client, err := s.oauth.AuthorizedClient(r) 232 if err != nil { 233 l.Error("failed to get authorized client", "err", err) 234 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 235 return 236 } 237 238 tx, err := s.db.BeginTx(r.Context(), nil) 239 if err != nil { 240 l.Error("failed to start tx", "err", err) 241 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 242 return 243 } 244 defer tx.Rollback() 245 246 // We've already checked earlier if it's diff-based and title is empty, 247 // so if it's still empty now, it's intentionally skipped owing to format-patch. 248 if title == "" || body == "" { 249 formatPatches, err := patchutil.ExtractPatches(patch) 250 if err != nil { 251 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 252 return 253 } 254 if len(formatPatches) == 0 { 255 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 256 return 257 } 258 259 if title == "" { 260 title = formatPatches[0].Title 261 } 262 if body == "" { 263 body = formatPatches[0].Body 264 } 265 } 266 267 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 268 269 rkey := tid.TID() 270 271 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 272 if err != nil { 273 l.Error("failed to upload patch", "err", err) 274 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 275 return 276 } 277 278 now := time.Now() 279 280 pull := &models.Pull{ 281 Title: title, 282 Body: body, 283 TargetBranch: targetBranch, 284 OwnerDid: userDid.String(), 285 RepoDid: syntax.DID(repo.RepoDid), 286 Rkey: rkey, 287 Mentions: mentions, 288 References: references, 289 Submissions: []*models.PullSubmission{ 290 { 291 Patch: patch, 292 Combined: combined, 293 SourceRev: sourceRev, 294 Blob: *blob.Blob, 295 Created: now, 296 }, 297 }, 298 PullSource: pullSource, 299 State: models.PullOpen, 300 Created: now, 301 Repo: repo, 302 } 303 304 pull.Blobs = models.ParseBlobs(r.PostForm["blobs"], body) 305 306 record := pull.AsRecord() 307 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 308 Collection: tangled.RepoPullNSID, 309 Repo: userDid.String(), 310 Rkey: rkey, 311 Record: knotcompat.Pull(&record), 312 }) 313 if err != nil { 314 l.Error("failed to create pull request", "err", err) 315 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 316 return 317 } 318 319 err = db.PutPull(tx, pull) 320 if err != nil { 321 l.Error("failed to create pull request in database", "err", err) 322 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 323 return 324 } 325 pullId, err := db.NextPullId(tx, repo.RepoDid) 326 if err != nil { 327 s.logger.Error("failed to get pull id", "err", err) 328 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 329 return 330 } 331 332 if err = tx.Commit(); err != nil { 333 l.Error("failed to commit transaction for pull request", "err", err) 334 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 335 return 336 } 337 338 s.notifier.NewPull(r.Context(), pull) 339 340 s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo) 341 342 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 343 s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 344} 345 346func (s *Pulls) createStackedPullRequest( 347 w http.ResponseWriter, 348 r *http.Request, 349 repo *models.Repo, 350 userDid syntax.DID, 351 targetBranch string, 352 patch string, 353 sourceRev string, 354 pullSource *models.PullSource, 355 stackTitles, stackBodies map[string]string, 356 stackBlobs map[string][]string, 357) { 358 l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev) 359 360 // run some necessary checks for stacked-prs first 361 362 formatPatches, err := patchutil.ExtractPatches(patch) 363 if err != nil { 364 l.Error("failed to extract patches", "err", err) 365 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 366 return 367 } 368 369 // must have atleast 1 patch to begin with 370 if len(formatPatches) == 0 { 371 l.Error("empty patches") 372 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 373 return 374 } 375 376 client, err := s.oauth.AuthorizedClient(r) 377 if err != nil { 378 l.Error("failed to get authorized client", "err", err) 379 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 380 return 381 } 382 383 // first upload all blobs 384 blobs := make([]*lexutil.LexBlob, len(formatPatches)) 385 for i, p := range formatPatches { 386 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 387 if err != nil { 388 l.Error("failed to upload patch blob", "err", err, "patch_index", i) 389 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 390 return 391 } 392 l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 393 blobs[i] = blob.Blob 394 } 395 396 // build a stack out of this patch 397 stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies, stackBlobs) 398 if err != nil { 399 l.Error("failed to create stack", "err", err) 400 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 401 return 402 } 403 404 // apply all record creations at once 405 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 406 for _, p := range stack { 407 record := p.AsRecord() 408 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 409 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 410 Collection: tangled.RepoPullNSID, 411 Rkey: &p.Rkey, 412 Value: knotcompat.Pull(&record), 413 }, 414 }) 415 } 416 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 417 Repo: userDid.String(), 418 Writes: writes, 419 }) 420 if err != nil { 421 l.Error("failed to create stacked pull request", "err", err) 422 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 423 return 424 } 425 426 // create all pulls at once 427 tx, err := s.db.BeginTx(r.Context(), nil) 428 if err != nil { 429 l.Error("failed to start tx", "err", err) 430 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 431 return 432 } 433 defer tx.Rollback() 434 435 for _, p := range stack { 436 err = db.PutPull(tx, p) 437 if err != nil { 438 l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey) 439 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 440 return 441 } 442 443 } 444 445 if err = tx.Commit(); err != nil { 446 l.Error("failed to commit transaction for pull requests", "err", err) 447 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 448 return 449 } 450 451 // notify about each pull 452 // 453 // this is performed after tx.Commit, because it could result in a locked DB otherwise 454 for _, p := range stack { 455 s.notifier.NewPull(r.Context(), p) 456 } 457 458 s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo) 459 460 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 461 s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 462} 463 464func (s *Pulls) newStack( 465 ctx context.Context, 466 repo *models.Repo, 467 userDid syntax.DID, 468 targetBranch string, 469 pullSource *models.PullSource, 470 formatPatches []types.FormatPatch, 471 blobs []*lexutil.LexBlob, 472 stackTitles, stackBodies map[string]string, 473 stackBlobs map[string][]string, 474) (models.Stack, error) { 475 var stack models.Stack 476 var parentAtUri *syntax.ATURI 477 for i, fp := range formatPatches { 478 // all patches must have a jj change-id 479 cid, err := fp.ChangeId() 480 if err != nil { 481 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 482 } 483 484 title := fp.Title 485 body := fp.Body 486 if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" { 487 title = override 488 } 489 if override, ok := stackBodies[cid]; ok { 490 body = override 491 } 492 rkey := tid.TID() 493 494 mentions, references := s.mentionsResolver.Resolve(ctx, body) 495 496 now := time.Now() 497 498 pull := models.Pull{ 499 Title: title, 500 Body: body, 501 TargetBranch: targetBranch, 502 OwnerDid: userDid.String(), 503 RepoDid: syntax.DID(repo.RepoDid), 504 Rkey: rkey, 505 Mentions: mentions, 506 References: references, 507 Submissions: []*models.PullSubmission{ 508 { 509 Patch: fp.Raw, 510 SourceRev: fp.SHA, 511 Combined: fp.Raw, 512 Blob: *blobs[i], 513 Created: now, 514 }, 515 }, 516 PullSource: pullSource, 517 Created: now, 518 State: models.PullOpen, 519 520 DependentOn: parentAtUri, 521 Repo: repo, 522 } 523 pull.Blobs = models.ParseBlobs(stackBlobs[cid], body) 524 525 stack = append(stack, &pull) 526 527 parent := pull.AtUri() 528 parentAtUri = &parent 529 } 530 531 return stack, nil 532}