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/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 forkRkey := strings.ToLower(repoString[1])
102 fork, err := db.GetForkByDid(s.db, forkOwnerDid, forkRkey)
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, "fork_rkey", forkRkey)
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 forkDid := syntax.DID(fork.RepoDid)
186 pullSource := &models.PullSource{
187 Branch: sourceBranch,
188 RepoDid: &forkDid,
189 }
190
191 s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies)
192}
193
194func (s *Pulls) createPullRequest(
195 w http.ResponseWriter,
196 r *http.Request,
197 repo *models.Repo,
198 userDid syntax.DID,
199 title, body, targetBranch string,
200 patch string,
201 combined string,
202 sourceRev string,
203 pullSource *models.PullSource,
204 isStacked bool,
205 stackTitles, stackBodies map[string]string,
206) {
207 l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked)
208
209 if isStacked {
210 // creates a series of PRs, each linking to the previous, identified by jj's change-id
211 s.createStackedPullRequest(
212 w,
213 r,
214 repo,
215 userDid,
216 targetBranch,
217 patch,
218 sourceRev,
219 pullSource,
220 stackTitles,
221 stackBodies,
222 )
223 return
224 }
225
226 client, err := s.oauth.AuthorizedClient(r)
227 if err != nil {
228 l.Error("failed to get authorized client", "err", err)
229 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
230 return
231 }
232
233 tx, err := s.db.BeginTx(r.Context(), nil)
234 if err != nil {
235 l.Error("failed to start tx", "err", err)
236 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
237 return
238 }
239 defer tx.Rollback()
240
241 // We've already checked earlier if it's diff-based and title is empty,
242 // so if it's still empty now, it's intentionally skipped owing to format-patch.
243 if title == "" || body == "" {
244 formatPatches, err := patchutil.ExtractPatches(patch)
245 if err != nil {
246 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
247 return
248 }
249 if len(formatPatches) == 0 {
250 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
251 return
252 }
253
254 if title == "" {
255 title = formatPatches[0].Title
256 }
257 if body == "" {
258 body = formatPatches[0].Body
259 }
260 }
261
262 mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
263
264 rkey := tid.TID()
265
266 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip)
267 if err != nil {
268 l.Error("failed to upload patch", "err", err)
269 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
270 return
271 }
272
273 now := time.Now()
274
275 pull := &models.Pull{
276 Title: title,
277 Body: body,
278 TargetBranch: targetBranch,
279 OwnerDid: userDid.String(),
280 RepoDid: syntax.DID(repo.RepoDid),
281 Rkey: rkey,
282 Mentions: mentions,
283 References: references,
284 Submissions: []*models.PullSubmission{
285 {
286 Patch: patch,
287 Combined: combined,
288 SourceRev: sourceRev,
289 Blob: *blob.Blob,
290 Created: now,
291 },
292 },
293 PullSource: pullSource,
294 State: models.PullOpen,
295 Created: now,
296 Repo: repo,
297 }
298
299 record := pull.AsRecord()
300 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
301 Collection: tangled.RepoPullNSID,
302 Repo: userDid.String(),
303 Rkey: rkey,
304 Record: &lexutil.LexiconTypeDecoder{
305 Val: &record,
306 },
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 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId))
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: &lexutil.LexiconTypeDecoder{
407 Val: &record,
408 },
409 },
410 })
411 }
412 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
413 Repo: userDid.String(),
414 Writes: writes,
415 })
416 if err != nil {
417 l.Error("failed to create stacked pull request", "err", err)
418 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
419 return
420 }
421
422 // create all pulls at once
423 tx, err := s.db.BeginTx(r.Context(), nil)
424 if err != nil {
425 l.Error("failed to start tx", "err", err)
426 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
427 return
428 }
429 defer tx.Rollback()
430
431 for _, p := range stack {
432 err = db.PutPull(tx, p)
433 if err != nil {
434 l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey)
435 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
436 return
437 }
438
439 }
440
441 if err = tx.Commit(); err != nil {
442 l.Error("failed to commit transaction for pull requests", "err", err)
443 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
444 return
445 }
446
447 // notify about each pull
448 //
449 // this is performed after tx.Commit, because it could result in a locked DB otherwise
450 for _, p := range stack {
451 s.notifier.NewPull(r.Context(), p)
452 }
453
454 s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo)
455
456 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
457 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
458}
459
460func (s *Pulls) newStack(
461 ctx context.Context,
462 repo *models.Repo,
463 userDid syntax.DID,
464 targetBranch string,
465 pullSource *models.PullSource,
466 formatPatches []types.FormatPatch,
467 blobs []*lexutil.LexBlob,
468 stackTitles, stackBodies map[string]string,
469) (models.Stack, error) {
470 var stack models.Stack
471 var parentAtUri *syntax.ATURI
472 for i, fp := range formatPatches {
473 // all patches must have a jj change-id
474 cid, err := fp.ChangeId()
475 if err != nil {
476 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
477 }
478
479 title := fp.Title
480 body := fp.Body
481 if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" {
482 title = override
483 }
484 if override, ok := stackBodies[cid]; ok {
485 body = override
486 }
487 rkey := tid.TID()
488
489 mentions, references := s.mentionsResolver.Resolve(ctx, body)
490
491 now := time.Now()
492
493 pull := models.Pull{
494 Title: title,
495 Body: body,
496 TargetBranch: targetBranch,
497 OwnerDid: userDid.String(),
498 RepoDid: syntax.DID(repo.RepoDid),
499 Rkey: rkey,
500 Mentions: mentions,
501 References: references,
502 Submissions: []*models.PullSubmission{
503 {
504 Patch: fp.Raw,
505 SourceRev: fp.SHA,
506 Combined: fp.Raw,
507 Blob: *blobs[i],
508 Created: now,
509 },
510 },
511 PullSource: pullSource,
512 Created: now,
513 State: models.PullOpen,
514
515 DependentOn: parentAtUri,
516 Repo: repo,
517 }
518
519 stack = append(stack, &pull)
520
521 parent := pull.AtUri()
522 parentAtUri = &parent
523 }
524
525 return stack, nil
526}