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