This repository has no description
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}