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