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 }
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: &lexutil.LexiconTypeDecoder{
311 Val: &record,
312 },
313 })
314 if err != nil {
315 l.Error("failed to create pull request", "err", err)
316 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
317 return
318 }
319
320 err = db.PutPull(tx, pull)
321 if err != nil {
322 l.Error("failed to create pull request in database", "err", err)
323 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
324 return
325 }
326 pullId, err := db.NextPullId(tx, repo.RepoAt())
327 if err != nil {
328 s.logger.Error("failed to get pull id", "err", err)
329 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
330 return
331 }
332
333 if err = tx.Commit(); err != nil {
334 l.Error("failed to commit transaction for pull request", "err", err)
335 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
336 return
337 }
338
339 s.notifier.NewPull(r.Context(), pull)
340
341 s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo)
342
343 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
344 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId))
345}
346
347func (s *Pulls) createStackedPullRequest(
348 w http.ResponseWriter,
349 r *http.Request,
350 repo *models.Repo,
351 userDid syntax.DID,
352 targetBranch string,
353 patch string,
354 sourceRev string,
355 pullSource *models.PullSource,
356 stackTitles, stackBodies 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)
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: &lexutil.LexiconTypeDecoder{
413 Val: &record,
414 },
415 },
416 })
417 }
418 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
419 Repo: userDid.String(),
420 Writes: writes,
421 })
422 if err != nil {
423 l.Error("failed to create stacked pull request", "err", err)
424 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
425 return
426 }
427
428 // create all pulls at once
429 tx, err := s.db.BeginTx(r.Context(), nil)
430 if err != nil {
431 l.Error("failed to start tx", "err", err)
432 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
433 return
434 }
435 defer tx.Rollback()
436
437 for _, p := range stack {
438 err = db.PutPull(tx, p)
439 if err != nil {
440 l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey)
441 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
442 return
443 }
444
445 }
446
447 if err = tx.Commit(); err != nil {
448 l.Error("failed to commit transaction for pull requests", "err", err)
449 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
450 return
451 }
452
453 // notify about each pull
454 //
455 // this is performed after tx.Commit, because it could result in a locked DB otherwise
456 for _, p := range stack {
457 s.notifier.NewPull(r.Context(), p)
458 }
459
460 s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo)
461
462 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
463 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
464}
465
466func (s *Pulls) newStack(
467 ctx context.Context,
468 repo *models.Repo,
469 userDid syntax.DID,
470 targetBranch string,
471 pullSource *models.PullSource,
472 formatPatches []types.FormatPatch,
473 blobs []*lexutil.LexBlob,
474 stackTitles, stackBodies map[string]string,
475) (models.Stack, error) {
476 var stack models.Stack
477 var parentAtUri *syntax.ATURI
478 for i, fp := range formatPatches {
479 // all patches must have a jj change-id
480 cid, err := fp.ChangeId()
481 if err != nil {
482 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
483 }
484
485 title := fp.Title
486 body := fp.Body
487 if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" {
488 title = override
489 }
490 if override, ok := stackBodies[cid]; ok {
491 body = override
492 }
493 rkey := tid.TID()
494
495 mentions, references := s.mentionsResolver.Resolve(ctx, body)
496
497 now := time.Now()
498
499 pull := models.Pull{
500 Title: title,
501 Body: body,
502 TargetBranch: targetBranch,
503 OwnerDid: userDid.String(),
504 RepoAt: repo.RepoAt(),
505 Rkey: rkey,
506 Mentions: mentions,
507 References: references,
508 Submissions: []*models.PullSubmission{
509 {
510 Patch: fp.Raw,
511 SourceRev: fp.SHA,
512 Combined: fp.Raw,
513 Blob: *blobs[i],
514 Created: now,
515 },
516 },
517 PullSource: pullSource,
518 Created: now,
519 State: models.PullOpen,
520
521 DependentOn: parentAtUri,
522 Repo: repo,
523 }
524
525 stack = append(stack, &pull)
526
527 parent := pull.AtUri()
528 parentAtUri = &parent
529 }
530
531 return stack, nil
532}