Monorepo for Tangled
tangled.org
1package pulls
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "time"
8
9 "tangled.org/core/api/tangled"
10 "tangled.org/core/appview/compat113"
11 "tangled.org/core/appview/db"
12 "tangled.org/core/appview/models"
13 "tangled.org/core/appview/oauth"
14 "tangled.org/core/appview/pages"
15 "tangled.org/core/appview/pages/repoinfo"
16 "tangled.org/core/appview/reporesolver"
17 "tangled.org/core/appview/xrpcclient"
18 "tangled.org/core/orm"
19 "tangled.org/core/patchutil"
20 "tangled.org/core/types"
21 "tangled.org/core/xrpc"
22
23 comatproto "github.com/bluesky-social/indigo/api/atproto"
24 "github.com/bluesky-social/indigo/atproto/syntax"
25 lexutil "github.com/bluesky-social/indigo/lex/util"
26)
27
28func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
29 l := s.logger.With("handler", "ResubmitPull")
30
31 user := s.oauth.GetMultiAccountUser(r)
32 if user != nil {
33 l = l.With("user", user.Did)
34 }
35
36 pull, ok := r.Context().Value("pull").(*models.Pull)
37 if !ok {
38 l.Error("failed to get pull")
39 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
40 return
41 }
42 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
43
44 switch r.Method {
45 case http.MethodGet:
46 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
47 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
48 Pull: pull,
49 })
50 return
51 case http.MethodPost:
52 if pull.IsPatchBased() {
53 s.resubmitPatch(w, r)
54 return
55 } else if pull.IsBranchBased() {
56 s.resubmitBranch(w, r)
57 return
58 } else if pull.IsForkBased() {
59 s.resubmitFork(w, r)
60 return
61 }
62 }
63}
64
65func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
66 l := s.logger.With("handler", "resubmitPatch")
67
68 user := s.oauth.GetMultiAccountUser(r)
69 if user != nil {
70 l = l.With("user", user.Did)
71 }
72
73 pull, ok := r.Context().Value("pull").(*models.Pull)
74 if !ok {
75 l.Error("failed to get pull")
76 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
77 return
78 }
79 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
80
81 if user == nil || user.Did != pull.OwnerDid {
82 l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid)
83 w.WriteHeader(http.StatusUnauthorized)
84 return
85 }
86
87 f, err := s.repoResolver.Resolve(r)
88 if err != nil {
89 l.Error("failed to get repo and knot", "err", err)
90 return
91 }
92
93 patch := r.FormValue("patch")
94
95 s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "")
96}
97
98func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
99 l := s.logger.With("handler", "resubmitBranch")
100
101 user := s.oauth.GetMultiAccountUser(r)
102 if user != nil {
103 l = l.With("user", user.Did)
104 }
105
106 pull, ok := r.Context().Value("pull").(*models.Pull)
107 if !ok {
108 l.Error("failed to get pull")
109 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
110 return
111 }
112 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch)
113
114 if user == nil || user.Did != pull.OwnerDid {
115 l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid)
116 w.WriteHeader(http.StatusUnauthorized)
117 return
118 }
119
120 f, err := s.repoResolver.Resolve(r)
121 if err != nil {
122 l.Error("failed to get repo and knot", "err", err)
123 return
124 }
125
126 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())}
127 if !roles.IsPushAllowed() {
128 l.Warn("unauthorized user - no push permission")
129 w.WriteHeader(http.StatusUnauthorized)
130 return
131 }
132
133 xrpcc := s.knotClient(f.Knot)
134
135 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch)
136 if err != nil {
137 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
138 l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err, "source_branch", pull.PullSource.Branch)
139 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
140 return
141 }
142 l.Error("compare request failed", "err", err, "source_branch", pull.PullSource.Branch)
143 s.pages.Notice(w, "resubmit-error", err.Error())
144 return
145 }
146
147 var comparison types.RepoFormatPatchResponse
148 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
149 l.Error("failed to decode XRPC compare response", "err", err)
150 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
151 return
152 }
153
154 sourceRev := comparison.Rev2
155 patch := comparison.FormatPatchRaw
156 combined := comparison.CombinedPatchRaw
157
158 s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev)
159}
160
161func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
162 l := s.logger.With("handler", "resubmitFork")
163
164 user := s.oauth.GetMultiAccountUser(r)
165 if user != nil {
166 l = l.With("user", user.Did)
167 }
168
169 pull, ok := r.Context().Value("pull").(*models.Pull)
170 if !ok {
171 l.Error("failed to get pull")
172 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
173 return
174 }
175 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch)
176
177 if user == nil || user.Did != pull.OwnerDid {
178 l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid)
179 w.WriteHeader(http.StatusUnauthorized)
180 return
181 }
182
183 f, err := s.repoResolver.Resolve(r)
184 if err != nil {
185 l.Error("failed to get repo and knot", "err", err)
186 return
187 }
188
189 forkRepo, err := db.GetRepoByDid(s.db, string(*pull.PullSource.RepoDid))
190 if err != nil {
191 l.Error("failed to get source repo", "err", err, "repo_did", pull.PullSource.RepoDid.String())
192 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
193 return
194 }
195
196 // update the hidden tracking branch to latest
197 client, err := s.oauth.ServiceClient(
198 r,
199 oauth.WithService(forkRepo.Knot),
200 oauth.WithLxm(tangled.RepoHiddenRefNSID),
201 oauth.WithDev(s.config.Core.Dev),
202 )
203 if err != nil {
204 l.Error("failed to connect to knot server", "err", err, "fork_knot", forkRepo.Knot)
205 return
206 }
207
208 resp, err := tangled.RepoHiddenRef(
209 r.Context(),
210 client,
211 &tangled.RepoHiddenRef_Input{
212 ForkRef: pull.PullSource.Branch,
213 RemoteRef: pull.TargetBranch,
214 Repo: forkRepo.RepoAt().String(),
215 },
216 )
217 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
218 s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err)
219 s.pages.Notice(w, "resubmit-error", xrpcerr.Error())
220 return
221 }
222 if !resp.Success {
223 l.Error("failed to update tracking ref", "err", resp.Error, "fork_ref", pull.PullSource.Branch, "remote_ref", pull.TargetBranch)
224 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
225 return
226 }
227
228 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
229 // extract patch by performing compare
230 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch)
231 if err != nil {
232 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
233 l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch)
234 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
235 return
236 }
237 l.Error("failed to compare branches", "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch)
238 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
239 return
240 }
241
242 var forkComparison types.RepoFormatPatchResponse
243 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
244 l.Error("failed to decode XRPC compare response for fork", "err", err)
245 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
246 return
247 }
248
249 // Use the fork comparison we already made
250 comparison := forkComparison
251
252 sourceRev := comparison.Rev2
253 patch := comparison.FormatPatchRaw
254 combined := comparison.CombinedPatchRaw
255
256 s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev)
257}
258
259func (s *Pulls) resubmitPullHelper(
260 w http.ResponseWriter,
261 r *http.Request,
262 repo *models.Repo,
263 userDid syntax.DID,
264 pull *models.Pull,
265 patch string,
266 combined string,
267 sourceRev string,
268) {
269 l := s.logger.With("handler", "resubmitPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
270
271 stack := r.Context().Value("stack").(models.Stack)
272 if stack != nil && len(stack) != 1 {
273 l.Info("resubmitting stacked PR", "stack_size", len(stack))
274 s.resubmitStackedPullHelper(w, r, repo, userDid, pull, patch)
275 return
276 }
277
278 if err := s.validator.ValidatePatch(&patch); err != nil {
279 s.pages.Notice(w, "resubmit-error", err.Error())
280 return
281 }
282
283 if patch == pull.LatestPatch() {
284 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
285 return
286 }
287
288 // validate sourceRev if branch/fork based
289 if pull.IsBranchBased() || pull.IsForkBased() {
290 if sourceRev == pull.LatestSha() {
291 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
292 return
293 }
294 }
295
296 pullAt := pull.AtUri()
297 newRoundNumber := len(pull.Submissions)
298 newPatch := patch
299 newSourceRev := sourceRev
300 combinedPatch := combined
301
302 client, err := s.oauth.AuthorizedClient(r)
303 if err != nil {
304 l.Error("failed to authorize client", "err", err)
305 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
306 return
307 }
308
309 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, userDid.String(), pull.Rkey)
310 if err != nil {
311 // failed to get record
312 l.Error("failed to get record from PDS", "err", err, "rkey", pull.Rkey)
313 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
314 return
315 }
316
317 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip)
318 if err != nil {
319 l.Error("failed to upload patch blob", "err", err)
320 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
321 return
322 }
323 record := pull.AsRecord()
324 record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{
325 CreatedAt: time.Now().Format(time.RFC3339),
326 PatchBlob: blob.Blob,
327 })
328
329 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
330 Collection: tangled.RepoPullNSID,
331 Repo: userDid.String(),
332 Rkey: pull.Rkey,
333 SwapRecord: ex.Cid,
334 Record: compat113.Pull(&record),
335 })
336 if err != nil {
337 l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey)
338 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
339 return
340 }
341
342 err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob)
343 if err != nil {
344 l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber)
345 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
346 return
347 }
348
349 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
350 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
351}
352
353func (s *Pulls) resubmitStackedPullHelper(
354 w http.ResponseWriter,
355 r *http.Request,
356 repo *models.Repo,
357 userDid syntax.DID,
358 pull *models.Pull,
359 patch string,
360) {
361 l := s.logger.With("handler", "resubmitStackedPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
362
363 targetBranch := pull.TargetBranch
364
365 origStack, _ := r.Context().Value("stack").(models.Stack)
366
367 formatPatches, err := patchutil.ExtractPatches(patch)
368 if err != nil {
369 l.Error("failed to extract patches", "err", err)
370 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.")
371 return
372 }
373
374 // must have atleast 1 patch to begin with
375 if len(formatPatches) == 0 {
376 l.Error("no patches found in the generated format-patch")
377 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.")
378 return
379 }
380
381 client, err := s.oauth.AuthorizedClient(r)
382 if err != nil {
383 l.Error("failed to get authorized client", "err", err)
384 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
385 return
386 }
387
388 // first upload all blobs
389 blobs := make([]*lexutil.LexBlob, len(formatPatches))
390 for i, p := range formatPatches {
391 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip)
392 if err != nil {
393 l.Error("failed to upload patch blob", "err", err, "patch_index", i)
394 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
395 return
396 }
397 l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches))
398 blobs[i] = blob.Blob
399 }
400
401 newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil)
402 if err != nil {
403 l.Error("failed to create resubmitted stack", "err", err)
404 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
405 return
406 }
407
408 // find the diff between the stacks, first, map them by changeId
409 origById := make(map[string]*models.Pull)
410 newById := make(map[string]*models.Pull)
411 for _, p := range origStack {
412 origById[p.LatestSubmission().ChangeId()] = p
413 }
414 for _, p := range newStack {
415 newById[p.LatestSubmission().ChangeId()] = p
416 }
417
418 // commits that got deleted: corresponding pull is closed
419 // commits that got added: new pull is created
420 // commits that got updated: corresponding pull is resubmitted & new round begins
421 additions := make(map[string]*models.Pull)
422 deletions := make(map[string]*models.Pull)
423 updated := make(map[string]struct{})
424
425 // pulls in original stack but not in new one
426 for _, op := range origStack {
427 if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok {
428 deletions[op.LatestSubmission().ChangeId()] = op
429 }
430 }
431
432 // pulls in new stack but not in original one
433 for _, np := range newStack {
434 if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok {
435 additions[np.LatestSubmission().ChangeId()] = np
436 }
437 }
438
439 // NOTE: this loop can be written in any of above blocks,
440 // but is written separately in the interest of simpler code
441 for _, np := range newStack {
442 if op, ok := origById[np.LatestSubmission().ChangeId()]; ok {
443 // pull exists in both stacks
444 updated[op.LatestSubmission().ChangeId()] = struct{}{}
445 }
446 }
447
448 // NOTE: we can go through the newStack and update dependent relations and
449 // rkeys now that we know which ones have been updated
450 // update dependentOn relations for the entire stack
451 var parentAt *syntax.ATURI
452 for _, np := range newStack {
453 if op, ok := origById[np.LatestSubmission().ChangeId()]; ok {
454 // pull exists in both stacks
455 np.Rkey = op.Rkey
456 }
457 np.DependentOn = parentAt
458 x := np.AtUri()
459 parentAt = &x
460 }
461
462 l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated))
463
464 tx, err := s.db.Begin()
465 if err != nil {
466 l.Error("failed to start transaction", "err", err)
467 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
468 return
469 }
470 defer tx.Rollback()
471
472 // pds updates to make
473 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
474
475 // deleted pulls are marked as deleted in the DB
476 for _, p := range deletions {
477 // do not do delete already merged PRs
478 if p.State == models.PullMerged {
479 continue
480 }
481
482 err := db.AbandonPulls(tx, orm.FilterEq("repo_did", string(p.RepoDid)), orm.FilterEq("at_uri", p.AtUri()))
483 if err != nil {
484 l.Error("failed to delete pull", "err", err, "pull_id", p.PullId)
485 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
486 return
487 }
488 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
489 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
490 Collection: tangled.RepoPullNSID,
491 Rkey: p.Rkey,
492 },
493 })
494 }
495
496 // new pulls are created
497 for _, p := range additions {
498 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip)
499 if err != nil {
500 l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId())
501 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
502 return
503 }
504 p.Submissions[0].Blob = *blob.Blob
505
506 if err = db.PutPull(tx, p); err != nil {
507 l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId())
508 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
509 return
510 }
511
512 record := p.AsRecord()
513 record.Rounds = []*tangled.RepoPull_Round{
514 {
515 CreatedAt: time.Now().Format(time.RFC3339),
516 PatchBlob: blob.Blob,
517 },
518 }
519 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
520 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
521 Collection: tangled.RepoPullNSID,
522 Rkey: &p.Rkey,
523 Value: compat113.Pull(&record),
524 },
525 })
526 }
527
528 // updated pulls are, well, updated; to start a new round
529 for id := range updated {
530 op, _ := origById[id]
531 np, _ := newById[id]
532
533 // do not update already merged PRs
534 if op.State == models.PullMerged {
535 continue
536 }
537
538 // resubmit the new pull
539 np.Rkey = op.Rkey
540 pullAt := op.AtUri()
541 newRoundNumber := len(op.Submissions)
542 newPatch := np.LatestPatch()
543 combinedPatch := np.LatestSubmission().Combined
544 newSourceRev := np.LatestSha()
545
546 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip)
547 if err != nil {
548 l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId)
549 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
550 return
551 }
552
553 // create new round
554 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob)
555 if err != nil {
556 l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber)
557 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
558 return
559 }
560
561 // update dependent-on relation
562 if np.DependentOn != nil {
563 err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri()))
564 if err != nil {
565 l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber)
566 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
567 return
568 }
569 }
570
571 record := np.AsRecord()
572 record.Rounds = op.AsRecord().Rounds
573 record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{
574 CreatedAt: time.Now().Format(time.RFC3339),
575 PatchBlob: blob.Blob,
576 })
577 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
578 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
579 Collection: tangled.RepoPullNSID,
580 Rkey: op.Rkey,
581 Value: compat113.Pull(&record),
582 },
583 })
584 }
585
586 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
587 Repo: userDid.String(),
588 Writes: writes,
589 })
590 if err != nil {
591 l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes))
592 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
593 return
594 }
595
596 err = tx.Commit()
597 if err != nil {
598 l.Error("failed to commit resubmit transaction", "err", err)
599 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
600 return
601 }
602
603 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
604 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
605}