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) {
42 l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked)
43
44 xrpcc := s.knotClient(repo.Knot)
45
46 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch)
47 if err != nil {
48 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
49 l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err)
50 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
51 return
52 }
53 l.Error("failed to compare", "err", err)
54 s.pages.Notice(w, "pull", err.Error())
55 return
56 }
57
58 var comparison types.RepoFormatPatchResponse
59 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
60 l.Error("failed to decode XRPC compare response", "err", err)
61 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
62 return
63 }
64
65 if len(comparison.FormatPatch) == 0 {
66 s.pages.Notice(w, "pull", "No commits between target and source.")
67 return
68 }
69
70 sourceRev := comparison.Rev2
71 patch := comparison.FormatPatchRaw
72 combined := comparison.CombinedPatchRaw
73 mergeBase := comparison.MergeBase
74
75 if err := validatePatch(&patch); err != nil {
76 s.logger.Error("failed to validate patch", "err", err)
77 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
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, mergeBase, pullSource, isStacked, stackTitles, stackBodies)
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) {
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)
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) {
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 mergeBase := comparison.MergeBase
182
183 if err := validatePatch(&patch); err != nil {
184 s.logger.Error("failed to validate patch", "err", err)
185 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
186 return
187 }
188
189 forkDid := syntax.DID(fork.RepoDid)
190 pullSource := &models.PullSource{
191 Branch: sourceBranch,
192 RepoDid: &forkDid,
193 }
194
195 s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, mergeBase, pullSource, isStacked, stackTitles, stackBodies)
196}
197
198func (s *Pulls) createPullRequest(
199 w http.ResponseWriter,
200 r *http.Request,
201 repo *models.Repo,
202 userDid syntax.DID,
203 title, body, targetBranch string,
204 patch string,
205 combined string,
206 sourceRev string,
207 mergeBase string,
208 pullSource *models.PullSource,
209 isStacked bool,
210 stackTitles, stackBodies map[string]string,
211) {
212 l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked)
213
214 if isStacked {
215 // creates a series of PRs, each linking to the previous, identified by jj's change-id
216 s.createStackedPullRequest(
217 w,
218 r,
219 repo,
220 userDid,
221 targetBranch,
222 patch,
223 sourceRev,
224 pullSource,
225 stackTitles,
226 stackBodies,
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 MergeBase: mergeBase,
295 Blob: *blob.Blob,
296 Created: now,
297 },
298 },
299 PullSource: pullSource,
300 State: models.PullOpen,
301 Created: now,
302 Repo: repo,
303 }
304
305 record := pull.AsRecord()
306 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
307 Collection: tangled.RepoPullNSID,
308 Repo: userDid.String(),
309 Rkey: rkey,
310 Record: knotcompat.Pull(&record),
311 })
312 if err != nil {
313 l.Error("failed to create pull request", "err", err)
314 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
315 return
316 }
317
318 err = db.PutPull(tx, pull)
319 if err != nil {
320 l.Error("failed to create pull request in database", "err", err)
321 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
322 return
323 }
324 pullId, err := db.NextPullId(tx, repo.RepoDid)
325 if err != nil {
326 s.logger.Error("failed to get pull id", "err", err)
327 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
328 return
329 }
330
331 if err = tx.Commit(); err != nil {
332 l.Error("failed to commit transaction for pull request", "err", err)
333 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
334 return
335 }
336
337 s.notifier.NewPull(r.Context(), pull)
338
339 s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo)
340
341 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
342 s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId))
343}
344
345func (s *Pulls) createStackedPullRequest(
346 w http.ResponseWriter,
347 r *http.Request,
348 repo *models.Repo,
349 userDid syntax.DID,
350 targetBranch string,
351 patch string,
352 sourceRev string,
353 pullSource *models.PullSource,
354 stackTitles, stackBodies map[string]string,
355) {
356 l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev)
357
358 // run some necessary checks for stacked-prs first
359
360 formatPatches, err := patchutil.ExtractPatches(patch)
361 if err != nil {
362 l.Error("failed to extract patches", "err", err)
363 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
364 return
365 }
366
367 // must have atleast 1 patch to begin with
368 if len(formatPatches) == 0 {
369 l.Error("empty patches")
370 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
371 return
372 }
373
374 client, err := s.oauth.AuthorizedClient(r)
375 if err != nil {
376 l.Error("failed to get authorized client", "err", err)
377 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
378 return
379 }
380
381 // first upload all blobs
382 blobs := make([]*lexutil.LexBlob, len(formatPatches))
383 for i, p := range formatPatches {
384 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip)
385 if err != nil {
386 l.Error("failed to upload patch blob", "err", err, "patch_index", i)
387 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
388 return
389 }
390 l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches))
391 blobs[i] = blob.Blob
392 }
393
394 // build a stack out of this patch
395 stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies)
396 if err != nil {
397 l.Error("failed to create stack", "err", err)
398 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
399 return
400 }
401
402 // apply all record creations at once
403 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
404 for _, p := range stack {
405 record := p.AsRecord()
406 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
407 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
408 Collection: tangled.RepoPullNSID,
409 Rkey: &p.Rkey,
410 Value: knotcompat.Pull(&record),
411 },
412 })
413 }
414 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
415 Repo: userDid.String(),
416 Writes: writes,
417 })
418 if err != nil {
419 l.Error("failed to create stacked pull request", "err", err)
420 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
421 return
422 }
423
424 // create all pulls at once
425 tx, err := s.db.BeginTx(r.Context(), nil)
426 if err != nil {
427 l.Error("failed to start tx", "err", err)
428 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
429 return
430 }
431 defer tx.Rollback()
432
433 for _, p := range stack {
434 err = db.PutPull(tx, p)
435 if err != nil {
436 l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey)
437 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
438 return
439 }
440
441 }
442
443 if err = tx.Commit(); err != nil {
444 l.Error("failed to commit transaction for pull requests", "err", err)
445 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
446 return
447 }
448
449 // notify about each pull
450 //
451 // this is performed after tx.Commit, because it could result in a locked DB otherwise
452 for _, p := range stack {
453 s.notifier.NewPull(r.Context(), p)
454 }
455
456 s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo)
457
458 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
459 s.pages.HxRedirect(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
460}
461
462func (s *Pulls) newStack(
463 ctx context.Context,
464 repo *models.Repo,
465 userDid syntax.DID,
466 targetBranch string,
467 pullSource *models.PullSource,
468 formatPatches []types.FormatPatch,
469 blobs []*lexutil.LexBlob,
470 stackTitles, stackBodies map[string]string,
471) (models.Stack, error) {
472 var stack models.Stack
473 var parentAtUri *syntax.ATURI
474 for i, fp := range formatPatches {
475 // all patches must have a jj change-id
476 cid, err := fp.ChangeId()
477 if err != nil {
478 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
479 }
480
481 title := fp.Title
482 body := fp.Body
483 if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" {
484 title = override
485 }
486 if override, ok := stackBodies[cid]; ok {
487 body = override
488 }
489 rkey := tid.TID()
490
491 mentions, references := s.mentionsResolver.Resolve(ctx, body)
492
493 now := time.Now()
494
495 pull := models.Pull{
496 Title: title,
497 Body: body,
498 TargetBranch: targetBranch,
499 OwnerDid: userDid.String(),
500 RepoDid: syntax.DID(repo.RepoDid),
501 Rkey: rkey,
502 Mentions: mentions,
503 References: references,
504 Submissions: []*models.PullSubmission{
505 {
506 Patch: fp.Raw,
507 SourceRev: fp.SHA,
508 Combined: fp.Raw,
509 Blob: *blobs[i],
510 Created: now,
511 },
512 },
513 PullSource: pullSource,
514 Created: now,
515 State: models.PullOpen,
516
517 DependentOn: parentAtUri,
518 Repo: repo,
519 }
520
521 stack = append(stack, &pull)
522
523 parent := pull.AtUri()
524 parentAtUri = &parent
525 }
526
527 return stack, nil
528}