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