forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package state
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "net/http"
12 "strconv"
13 "time"
14
15 "go.opentelemetry.io/otel/attribute"
16 "tangled.sh/tangled.sh/core/api/tangled"
17 "tangled.sh/tangled.sh/core/appview"
18 "tangled.sh/tangled.sh/core/appview/auth"
19 "tangled.sh/tangled.sh/core/appview/db"
20 "tangled.sh/tangled.sh/core/appview/pages"
21 "tangled.sh/tangled.sh/core/patchutil"
22 "tangled.sh/tangled.sh/core/telemetry"
23 "tangled.sh/tangled.sh/core/types"
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 "github.com/go-chi/chi/v5"
29)
30
31// htmx fragment
32func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
33 ctx, span := s.t.TraceStart(r.Context(), "PullActions")
34 defer span.End()
35
36 switch r.Method {
37 case http.MethodGet:
38 user := s.auth.GetUser(r)
39 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
40 if err != nil {
41 log.Println("failed to get repo and knot", err)
42 return
43 }
44
45 pull, ok := ctx.Value("pull").(*db.Pull)
46 if !ok {
47 log.Println("failed to get pull")
48 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
49 return
50 }
51
52 roundNumberStr := chi.URLParam(r, "round")
53 roundNumber, err := strconv.Atoi(roundNumberStr)
54 if err != nil {
55 roundNumber = pull.LastRoundNumber()
56 }
57 if roundNumber >= len(pull.Submissions) {
58 http.Error(w, "bad round id", http.StatusBadRequest)
59 log.Println("failed to parse round id", err)
60 return
61 }
62
63 _, mergeSpan := s.t.TraceStart(ctx, "mergeCheck")
64 mergeCheckResponse := s.mergeCheck(ctx, f, pull)
65 mergeSpan.End()
66
67 resubmitResult := pages.Unknown
68 if user.Did == pull.OwnerDid {
69 _, resubmitSpan := s.t.TraceStart(ctx, "resubmitCheck")
70 resubmitResult = s.resubmitCheck(ctx, f, pull)
71 resubmitSpan.End()
72 }
73
74 _, renderSpan := s.t.TraceStart(ctx, "renderPullActions")
75 s.pages.PullActionsFragment(w, pages.PullActionsParams{
76 LoggedInUser: user,
77 RepoInfo: f.RepoInfo(ctx, s, user),
78 Pull: pull,
79 RoundNumber: roundNumber,
80 MergeCheck: mergeCheckResponse,
81 ResubmitCheck: resubmitResult,
82 })
83 renderSpan.End()
84 return
85 }
86}
87
88func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
89 ctx, span := s.t.TraceStart(r.Context(), "RepoSinglePull")
90 defer span.End()
91
92 user := s.auth.GetUser(r)
93 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
94 if err != nil {
95 log.Println("failed to get repo and knot", err)
96 span.RecordError(err)
97 return
98 }
99
100 pull, ok := ctx.Value("pull").(*db.Pull)
101 if !ok {
102 err := errors.New("failed to get pull from context")
103 log.Println(err)
104 span.RecordError(err)
105 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
106 return
107 }
108
109 attrs := telemetry.MapAttrs[string](map[string]string{
110 "pull.id": fmt.Sprintf("%d", pull.PullId),
111 "pull.owner": pull.OwnerDid,
112 })
113
114 span.SetAttributes(attrs...)
115
116 totalIdents := 1
117 for _, submission := range pull.Submissions {
118 totalIdents += len(submission.Comments)
119 }
120
121 identsToResolve := make([]string, totalIdents)
122
123 // populate idents
124 identsToResolve[0] = pull.OwnerDid
125 idx := 1
126 for _, submission := range pull.Submissions {
127 for _, comment := range submission.Comments {
128 identsToResolve[idx] = comment.OwnerDid
129 idx += 1
130 }
131 }
132
133 resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve)
134 didHandleMap := make(map[string]string)
135 for _, identity := range resolvedIds {
136 if !identity.Handle.IsInvalidHandle() {
137 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
138 } else {
139 didHandleMap[identity.DID.String()] = identity.DID.String()
140 }
141 }
142 span.SetAttributes(attribute.Int("identities.resolved", len(resolvedIds)))
143
144 mergeCheckResponse := s.mergeCheck(ctx, f, pull)
145
146 resubmitResult := pages.Unknown
147 if user != nil && user.Did == pull.OwnerDid {
148 resubmitResult = s.resubmitCheck(ctx, f, pull)
149 }
150
151 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
152 LoggedInUser: user,
153 RepoInfo: f.RepoInfo(ctx, s, user),
154 DidHandleMap: didHandleMap,
155 Pull: pull,
156 MergeCheck: mergeCheckResponse,
157 ResubmitCheck: resubmitResult,
158 })
159}
160
161func (s *State) mergeCheck(ctx context.Context, f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse {
162 if pull.State == db.PullMerged {
163 return types.MergeCheckResponse{}
164 }
165
166 secret, err := db.GetRegistrationKey(s.db, f.Knot)
167 if err != nil {
168 log.Printf("failed to get registration key: %v", err)
169 return types.MergeCheckResponse{
170 Error: "failed to check merge status: this knot is unregistered",
171 }
172 }
173
174 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
175 if err != nil {
176 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
177 return types.MergeCheckResponse{
178 Error: "failed to check merge status",
179 }
180 }
181
182 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch)
183 if err != nil {
184 log.Println("failed to check for mergeability:", err)
185 return types.MergeCheckResponse{
186 Error: "failed to check merge status",
187 }
188 }
189 switch resp.StatusCode {
190 case 404:
191 return types.MergeCheckResponse{
192 Error: "failed to check merge status: this knot does not support PRs",
193 }
194 case 400:
195 return types.MergeCheckResponse{
196 Error: "failed to check merge status: does this knot support PRs?",
197 }
198 }
199
200 respBody, err := io.ReadAll(resp.Body)
201 if err != nil {
202 log.Println("failed to read merge check response body")
203 return types.MergeCheckResponse{
204 Error: "failed to check merge status: knot is not speaking the right language",
205 }
206 }
207 defer resp.Body.Close()
208
209 var mergeCheckResponse types.MergeCheckResponse
210 err = json.Unmarshal(respBody, &mergeCheckResponse)
211 if err != nil {
212 log.Println("failed to unmarshal merge check response", err)
213 return types.MergeCheckResponse{
214 Error: "failed to check merge status: knot is not speaking the right language",
215 }
216 }
217
218 return mergeCheckResponse
219}
220
221func (s *State) resubmitCheck(ctx context.Context, f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
222 ctx, span := s.t.TraceStart(ctx, "resubmitCheck")
223 defer span.End()
224
225 span.SetAttributes(attribute.Int("pull.id", pull.PullId))
226
227 if pull.State == db.PullMerged || pull.PullSource == nil {
228 span.SetAttributes(attribute.String("result", "Unknown"))
229 return pages.Unknown
230 }
231
232 var knot, ownerDid, repoName string
233
234 if pull.PullSource.RepoAt != nil {
235 // fork-based pulls
236 span.SetAttributes(attribute.Bool("isForkBased", true))
237 sourceRepo, err := db.GetRepoByAtUri(ctx, s.db, pull.PullSource.RepoAt.String())
238 if err != nil {
239 log.Println("failed to get source repo", err)
240 span.RecordError(err)
241 span.SetAttributes(attribute.String("error", "failed_to_get_source_repo"))
242 span.SetAttributes(attribute.String("result", "Unknown"))
243 return pages.Unknown
244 }
245
246 knot = sourceRepo.Knot
247 ownerDid = sourceRepo.Did
248 repoName = sourceRepo.Name
249 } else {
250 // pulls within the same repo
251 span.SetAttributes(attribute.Bool("isBranchBased", true))
252 knot = f.Knot
253 ownerDid = f.OwnerDid()
254 repoName = f.RepoName
255 }
256
257 span.SetAttributes(
258 attribute.String("knot", knot),
259 attribute.String("ownerDid", ownerDid),
260 attribute.String("repoName", repoName),
261 attribute.String("sourceBranch", pull.PullSource.Branch),
262 )
263
264 us, err := NewUnsignedClient(knot, s.config.Dev)
265 if err != nil {
266 log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
267 span.RecordError(err)
268 span.SetAttributes(attribute.String("error", "failed_to_setup_client"))
269 span.SetAttributes(attribute.String("result", "Unknown"))
270 return pages.Unknown
271 }
272
273 resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
274 if err != nil {
275 log.Println("failed to reach knotserver", err)
276 span.RecordError(err)
277 span.SetAttributes(attribute.String("error", "failed_to_reach_knotserver"))
278 span.SetAttributes(attribute.String("result", "Unknown"))
279 return pages.Unknown
280 }
281
282 body, err := io.ReadAll(resp.Body)
283 if err != nil {
284 log.Printf("error reading response body: %v", err)
285 span.RecordError(err)
286 span.SetAttributes(attribute.String("error", "failed_to_read_response"))
287 span.SetAttributes(attribute.String("result", "Unknown"))
288 return pages.Unknown
289 }
290 defer resp.Body.Close()
291
292 var result types.RepoBranchResponse
293 if err := json.Unmarshal(body, &result); err != nil {
294 log.Println("failed to parse response:", err)
295 span.RecordError(err)
296 span.SetAttributes(attribute.String("error", "failed_to_parse_response"))
297 span.SetAttributes(attribute.String("result", "Unknown"))
298 return pages.Unknown
299 }
300
301 latestSubmission := pull.Submissions[pull.LastRoundNumber()]
302
303 span.SetAttributes(
304 attribute.String("latestSubmission.SourceRev", latestSubmission.SourceRev),
305 attribute.String("branch.Hash", result.Branch.Hash),
306 )
307
308 if latestSubmission.SourceRev != result.Branch.Hash {
309 fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
310 span.SetAttributes(attribute.String("result", "ShouldResubmit"))
311 return pages.ShouldResubmit
312 }
313
314 span.SetAttributes(attribute.String("result", "ShouldNotResubmit"))
315 return pages.ShouldNotResubmit
316}
317
318func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
319 ctx, span := s.t.TraceStart(r.Context(), "RepoPullPatch")
320 defer span.End()
321
322 user := s.auth.GetUser(r.WithContext(ctx))
323 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
324 if err != nil {
325 log.Println("failed to get repo and knot", err)
326 span.RecordError(err)
327 return
328 }
329
330 pull, ok := ctx.Value("pull").(*db.Pull)
331 if !ok {
332 err := errors.New("failed to get pull from context")
333 log.Println(err)
334 span.RecordError(err)
335 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
336 return
337 }
338
339 roundId := chi.URLParam(r, "round")
340 roundIdInt, err := strconv.Atoi(roundId)
341 if err != nil || roundIdInt >= len(pull.Submissions) {
342 http.Error(w, "bad round id", http.StatusBadRequest)
343 log.Println("failed to parse round id", err)
344 span.RecordError(err)
345 span.SetAttributes(attribute.String("error", "bad_round_id"))
346 return
347 }
348
349 span.SetAttributes(
350 attribute.Int("pull.id", pull.PullId),
351 attribute.Int("round", roundIdInt),
352 attribute.String("pull.owner", pull.OwnerDid),
353 )
354
355 identsToResolve := []string{pull.OwnerDid}
356 resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve)
357 didHandleMap := make(map[string]string)
358 for _, identity := range resolvedIds {
359 if !identity.Handle.IsInvalidHandle() {
360 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
361 } else {
362 didHandleMap[identity.DID.String()] = identity.DID.String()
363 }
364 }
365 span.SetAttributes(attribute.Int("identities.resolved", len(resolvedIds)))
366
367 diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
368
369 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
370 LoggedInUser: user,
371 DidHandleMap: didHandleMap,
372 RepoInfo: f.RepoInfo(ctx, s, user),
373 Pull: pull,
374 Round: roundIdInt,
375 Submission: pull.Submissions[roundIdInt],
376 Diff: &diff,
377 })
378}
379
380func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
381 ctx, span := s.t.TraceStart(r.Context(), "RepoPullInterdiff")
382 defer span.End()
383
384 user := s.auth.GetUser(r)
385
386 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
387 if err != nil {
388 log.Println("failed to get repo and knot", err)
389 return
390 }
391
392 pull, ok := ctx.Value("pull").(*db.Pull)
393 if !ok {
394 log.Println("failed to get pull")
395 s.pages.Notice(w, "pull-error", "Failed to get pull.")
396 return
397 }
398
399 _, roundSpan := s.t.TraceStart(ctx, "parseRound")
400 roundId := chi.URLParam(r, "round")
401 roundIdInt, err := strconv.Atoi(roundId)
402 if err != nil || roundIdInt >= len(pull.Submissions) {
403 http.Error(w, "bad round id", http.StatusBadRequest)
404 log.Println("failed to parse round id", err)
405 roundSpan.End()
406 return
407 }
408
409 if roundIdInt == 0 {
410 http.Error(w, "bad round id", http.StatusBadRequest)
411 log.Println("cannot interdiff initial submission")
412 roundSpan.End()
413 return
414 }
415 roundSpan.End()
416
417 _, identSpan := s.t.TraceStart(ctx, "resolveIdentities")
418 identsToResolve := []string{pull.OwnerDid}
419 resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve)
420 didHandleMap := make(map[string]string)
421 for _, identity := range resolvedIds {
422 if !identity.Handle.IsInvalidHandle() {
423 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
424 } else {
425 didHandleMap[identity.DID.String()] = identity.DID.String()
426 }
427 }
428 identSpan.End()
429
430 _, diffSpan := s.t.TraceStart(ctx, "calculateInterdiff")
431 currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
432 if err != nil {
433 log.Println("failed to interdiff; current patch malformed")
434 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
435 diffSpan.End()
436 return
437 }
438
439 previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
440 if err != nil {
441 log.Println("failed to interdiff; previous patch malformed")
442 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
443 diffSpan.End()
444 return
445 }
446
447 interdiff := patchutil.Interdiff(previousPatch, currentPatch)
448 diffSpan.End()
449
450 _, renderSpan := s.t.TraceStart(ctx, "renderInterdiffPage")
451 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
452 LoggedInUser: s.auth.GetUser(r.WithContext(ctx)),
453 RepoInfo: f.RepoInfo(ctx, s, user),
454 Pull: pull,
455 Round: roundIdInt,
456 DidHandleMap: didHandleMap,
457 Interdiff: interdiff,
458 })
459 renderSpan.End()
460 return
461}
462
463func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
464 ctx, span := s.t.TraceStart(r.Context(), "RepoPullPatchRaw")
465 defer span.End()
466
467 pull, ok := ctx.Value("pull").(*db.Pull)
468 if !ok {
469 log.Println("failed to get pull")
470 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
471 return
472 }
473
474 _, roundSpan := s.t.TraceStart(ctx, "parseRound")
475 roundId := chi.URLParam(r, "round")
476 roundIdInt, err := strconv.Atoi(roundId)
477 if err != nil || roundIdInt >= len(pull.Submissions) {
478 http.Error(w, "bad round id", http.StatusBadRequest)
479 log.Println("failed to parse round id", err)
480 roundSpan.End()
481 return
482 }
483 roundSpan.End()
484
485 _, identSpan := s.t.TraceStart(ctx, "resolveIdentities")
486 identsToResolve := []string{pull.OwnerDid}
487 resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve)
488 didHandleMap := make(map[string]string)
489 for _, identity := range resolvedIds {
490 if !identity.Handle.IsInvalidHandle() {
491 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
492 } else {
493 didHandleMap[identity.DID.String()] = identity.DID.String()
494 }
495 }
496 identSpan.End()
497
498 _, writeSpan := s.t.TraceStart(ctx, "writePatch")
499 w.Header().Set("Content-Type", "text/plain")
500 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
501 writeSpan.End()
502}
503
504func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
505 ctx, span := s.t.TraceStart(r.Context(), "RepoPulls")
506 defer span.End()
507
508 user := s.auth.GetUser(r)
509 params := r.URL.Query()
510
511 _, stateSpan := s.t.TraceStart(ctx, "determinePullState")
512 state := db.PullOpen
513 switch params.Get("state") {
514 case "closed":
515 state = db.PullClosed
516 case "merged":
517 state = db.PullMerged
518 }
519 stateSpan.End()
520
521 _, repoSpan := s.t.TraceStart(ctx, "resolveRepo")
522 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
523 if err != nil {
524 log.Println("failed to get repo and knot", err)
525 repoSpan.End()
526 return
527 }
528 repoSpan.End()
529
530 _, pullsSpan := s.t.TraceStart(ctx, "getPulls")
531 pulls, err := db.GetPulls(ctx, s.db, f.RepoAt, state)
532 if err != nil {
533 log.Println("failed to get pulls", err)
534 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
535 pullsSpan.End()
536 return
537 }
538 pullsSpan.End()
539
540 _, sourceRepoSpan := s.t.TraceStart(ctx, "resolvePullSources")
541 for _, p := range pulls {
542 var pullSourceRepo *db.Repo
543 if p.PullSource != nil {
544 if p.PullSource.RepoAt != nil {
545 pullSourceRepo, err = db.GetRepoByAtUri(ctx, s.db, p.PullSource.RepoAt.String())
546 if err != nil {
547 log.Printf("failed to get repo by at uri: %v", err)
548 continue
549 } else {
550 p.PullSource.Repo = pullSourceRepo
551 }
552 }
553 }
554 }
555 sourceRepoSpan.End()
556
557 _, identSpan := s.t.TraceStart(ctx, "resolveIdentities")
558 identsToResolve := make([]string, len(pulls))
559 for i, pull := range pulls {
560 identsToResolve[i] = pull.OwnerDid
561 }
562 resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve)
563 didHandleMap := make(map[string]string)
564 for _, identity := range resolvedIds {
565 if !identity.Handle.IsInvalidHandle() {
566 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
567 } else {
568 didHandleMap[identity.DID.String()] = identity.DID.String()
569 }
570 }
571 identSpan.End()
572
573 _, renderSpan := s.t.TraceStart(ctx, "renderPullsPage")
574 s.pages.RepoPulls(w, pages.RepoPullsParams{
575 LoggedInUser: s.auth.GetUser(r.WithContext(ctx)),
576 RepoInfo: f.RepoInfo(ctx, s, user),
577 Pulls: pulls,
578 DidHandleMap: didHandleMap,
579 FilteringBy: state,
580 })
581 renderSpan.End()
582 return
583}
584
585func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
586 ctx, span := s.t.TraceStart(r.Context(), "PullComment")
587 defer span.End()
588
589 user := s.auth.GetUser(r.WithContext(ctx))
590 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
591 if err != nil {
592 log.Println("failed to get repo and knot", err)
593 return
594 }
595
596 pull, ok := ctx.Value("pull").(*db.Pull)
597 if !ok {
598 log.Println("failed to get pull")
599 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
600 return
601 }
602
603 _, roundSpan := s.t.TraceStart(ctx, "parseRoundNumber")
604 roundNumberStr := chi.URLParam(r, "round")
605 roundNumber, err := strconv.Atoi(roundNumberStr)
606 if err != nil || roundNumber >= len(pull.Submissions) {
607 http.Error(w, "bad round id", http.StatusBadRequest)
608 log.Println("failed to parse round id", err)
609 roundSpan.End()
610 return
611 }
612 roundSpan.End()
613
614 switch r.Method {
615 case http.MethodGet:
616 _, renderSpan := s.t.TraceStart(ctx, "renderCommentFragment")
617 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
618 LoggedInUser: user,
619 RepoInfo: f.RepoInfo(ctx, s, user),
620 Pull: pull,
621 RoundNumber: roundNumber,
622 })
623 renderSpan.End()
624 return
625 case http.MethodPost:
626 postCtx, postSpan := s.t.TraceStart(ctx, "CreateComment")
627 defer postSpan.End()
628
629 _, validateSpan := s.t.TraceStart(postCtx, "validateComment")
630 body := r.FormValue("body")
631 if body == "" {
632 s.pages.Notice(w, "pull", "Comment body is required")
633 validateSpan.End()
634 return
635 }
636 validateSpan.End()
637
638 // Start a transaction
639 _, txSpan := s.t.TraceStart(postCtx, "startTransaction")
640 tx, err := s.db.BeginTx(postCtx, nil)
641 if err != nil {
642 log.Println("failed to start transaction", err)
643 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
644 txSpan.End()
645 return
646 }
647 defer tx.Rollback()
648 txSpan.End()
649
650 createdAt := time.Now().Format(time.RFC3339)
651 ownerDid := user.Did
652
653 _, pullAtSpan := s.t.TraceStart(postCtx, "getPullAt")
654 pullAt, err := db.GetPullAt(postCtx, s.db, f.RepoAt, pull.PullId)
655 if err != nil {
656 log.Println("failed to get pull at", err)
657 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
658 pullAtSpan.End()
659 return
660 }
661 pullAtSpan.End()
662
663 _, atProtoSpan := s.t.TraceStart(postCtx, "createAtProtoRecord")
664 atUri := f.RepoAt.String()
665 client, _ := s.auth.AuthorizedClient(r.WithContext(postCtx))
666 atResp, err := comatproto.RepoPutRecord(postCtx, client, &comatproto.RepoPutRecord_Input{
667 Collection: tangled.RepoPullCommentNSID,
668 Repo: user.Did,
669 Rkey: appview.TID(),
670 Record: &lexutil.LexiconTypeDecoder{
671 Val: &tangled.RepoPullComment{
672 Repo: &atUri,
673 Pull: string(pullAt),
674 Owner: &ownerDid,
675 Body: body,
676 CreatedAt: createdAt,
677 },
678 },
679 })
680 if err != nil {
681 log.Println("failed to create pull comment", err)
682 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
683 atProtoSpan.End()
684 return
685 }
686 atProtoSpan.End()
687
688 // Create the pull comment in the database with the commentAt field
689 _, dbSpan := s.t.TraceStart(postCtx, "createDbComment")
690 commentId, err := db.NewPullComment(postCtx, tx, &db.PullComment{
691 OwnerDid: user.Did,
692 RepoAt: f.RepoAt.String(),
693 PullId: pull.PullId,
694 Body: body,
695 CommentAt: atResp.Uri,
696 SubmissionId: pull.Submissions[roundNumber].ID,
697 })
698 if err != nil {
699 log.Println("failed to create pull comment", err)
700 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
701 dbSpan.End()
702 return
703 }
704 dbSpan.End()
705
706 if err = tx.Commit(); err != nil {
707 log.Println("failed to commit transaction", err)
708 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
709 return
710 }
711
712 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
713 return
714 }
715}
716
717func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
718 ctx, span := s.t.TraceStart(r.Context(), "NewPull")
719 defer span.End()
720
721 user := s.auth.GetUser(r.WithContext(ctx))
722 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
723 if err != nil {
724 log.Println("failed to get repo and knot", err)
725 span.RecordError(err)
726 return
727 }
728
729 switch r.Method {
730 case http.MethodGet:
731 span.SetAttributes(attribute.String("method", "GET"))
732
733 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
734 if err != nil {
735 log.Printf("failed to create unsigned client for %s", f.Knot)
736 span.RecordError(err)
737 s.pages.Error503(w)
738 return
739 }
740
741 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
742 if err != nil {
743 log.Println("failed to reach knotserver", err)
744 span.RecordError(err)
745 return
746 }
747
748 body, err := io.ReadAll(resp.Body)
749 if err != nil {
750 log.Printf("Error reading response body: %v", err)
751 span.RecordError(err)
752 return
753 }
754
755 var result types.RepoBranchesResponse
756 err = json.Unmarshal(body, &result)
757 if err != nil {
758 log.Println("failed to parse response:", err)
759 span.RecordError(err)
760 return
761 }
762
763 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
764 LoggedInUser: user,
765 RepoInfo: f.RepoInfo(ctx, s, user),
766 Branches: result.Branches,
767 })
768 case http.MethodPost:
769 span.SetAttributes(attribute.String("method", "POST"))
770
771 title := r.FormValue("title")
772 body := r.FormValue("body")
773 targetBranch := r.FormValue("targetBranch")
774 fromFork := r.FormValue("fork")
775 sourceBranch := r.FormValue("sourceBranch")
776 patch := r.FormValue("patch")
777
778 span.SetAttributes(
779 attribute.String("targetBranch", targetBranch),
780 attribute.String("sourceBranch", sourceBranch),
781 attribute.Bool("hasFork", fromFork != ""),
782 attribute.Bool("hasPatch", patch != ""),
783 )
784
785 if targetBranch == "" {
786 s.pages.Notice(w, "pull", "Target branch is required.")
787 span.SetAttributes(attribute.String("error", "missing_target_branch"))
788 return
789 }
790
791 // Determine PR type based on input parameters
792 isPushAllowed := f.RepoInfo(ctx, s, user).Roles.IsPushAllowed()
793 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
794 isForkBased := fromFork != "" && sourceBranch != ""
795 isPatchBased := patch != "" && !isBranchBased && !isForkBased
796
797 span.SetAttributes(
798 attribute.Bool("isPushAllowed", isPushAllowed),
799 attribute.Bool("isBranchBased", isBranchBased),
800 attribute.Bool("isForkBased", isForkBased),
801 attribute.Bool("isPatchBased", isPatchBased),
802 )
803
804 if isPatchBased && !patchutil.IsFormatPatch(patch) {
805 if title == "" {
806 s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
807 span.SetAttributes(attribute.String("error", "missing_title_for_git_diff"))
808 return
809 }
810 }
811
812 // Validate we have at least one valid PR creation method
813 if !isBranchBased && !isPatchBased && !isForkBased {
814 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
815 span.SetAttributes(attribute.String("error", "no_valid_pr_method"))
816 return
817 }
818
819 // Can't mix branch-based and patch-based approaches
820 if isBranchBased && patch != "" {
821 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
822 span.SetAttributes(attribute.String("error", "mixed_pr_methods"))
823 return
824 }
825
826 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
827 if err != nil {
828 log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
829 span.RecordError(err)
830 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
831 return
832 }
833
834 caps, err := us.Capabilities()
835 if err != nil {
836 log.Println("error fetching knot caps", f.Knot, err)
837 span.RecordError(err)
838 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
839 return
840 }
841
842 span.SetAttributes(
843 attribute.Bool("caps.pullRequests.formatPatch", caps.PullRequests.FormatPatch),
844 attribute.Bool("caps.pullRequests.branchSubmissions", caps.PullRequests.BranchSubmissions),
845 attribute.Bool("caps.pullRequests.forkSubmissions", caps.PullRequests.ForkSubmissions),
846 attribute.Bool("caps.pullRequests.patchSubmissions", caps.PullRequests.PatchSubmissions),
847 )
848
849 if !caps.PullRequests.FormatPatch {
850 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
851 span.SetAttributes(attribute.String("error", "formatpatch_not_supported"))
852 return
853 }
854
855 // Handle the PR creation based on the type
856 if isBranchBased {
857 if !caps.PullRequests.BranchSubmissions {
858 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
859 span.SetAttributes(attribute.String("error", "branch_submissions_not_supported"))
860 return
861 }
862 s.handleBranchBasedPull(w, r.WithContext(ctx), f, user, title, body, targetBranch, sourceBranch)
863 } else if isForkBased {
864 if !caps.PullRequests.ForkSubmissions {
865 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
866 span.SetAttributes(attribute.String("error", "fork_submissions_not_supported"))
867 return
868 }
869 s.handleForkBasedPull(w, r.WithContext(ctx), f, user, fromFork, title, body, targetBranch, sourceBranch)
870 } else if isPatchBased {
871 if !caps.PullRequests.PatchSubmissions {
872 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
873 span.SetAttributes(attribute.String("error", "patch_submissions_not_supported"))
874 return
875 }
876 s.handlePatchBasedPull(w, r.WithContext(ctx), f, user, title, body, targetBranch, patch)
877 }
878 return
879 }
880}
881
882func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
883 ctx, span := s.t.TraceStart(r.Context(), "handleBranchBasedPull")
884 defer span.End()
885
886 span.SetAttributes(
887 attribute.String("targetBranch", targetBranch),
888 attribute.String("sourceBranch", sourceBranch),
889 )
890
891 pullSource := &db.PullSource{
892 Branch: sourceBranch,
893 }
894 recordPullSource := &tangled.RepoPull_Source{
895 Branch: sourceBranch,
896 }
897
898 // Generate a patch using /compare
899 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
900 if err != nil {
901 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
902 span.RecordError(err)
903 span.SetAttributes(attribute.String("error", "client_creation_failed"))
904 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
905 return
906 }
907
908 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
909 if err != nil {
910 log.Println("failed to compare", err)
911 span.RecordError(err)
912 span.SetAttributes(attribute.String("error", "comparison_failed"))
913 s.pages.Notice(w, "pull", err.Error())
914 return
915 }
916
917 sourceRev := comparison.Rev2
918 patch := comparison.Patch
919
920 span.SetAttributes(attribute.String("sourceRev", sourceRev))
921
922 if !patchutil.IsPatchValid(patch) {
923 span.SetAttributes(attribute.String("error", "invalid_patch_format"))
924 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
925 return
926 }
927
928 s.createPullRequest(w, r.WithContext(ctx), f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
929}
930
931func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
932 ctx, span := s.t.TraceStart(r.Context(), "handlePatchBasedPull")
933 defer span.End()
934
935 span.SetAttributes(attribute.String("targetBranch", targetBranch))
936
937 if !patchutil.IsPatchValid(patch) {
938 span.SetAttributes(attribute.String("error", "invalid_patch_format"))
939 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
940 return
941 }
942
943 s.createPullRequest(w, r.WithContext(ctx), f, user, title, body, targetBranch, patch, "", nil, nil)
944}
945
946func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
947 ctx, span := s.t.TraceStart(r.Context(), "handleForkBasedPull")
948 defer span.End()
949
950 span.SetAttributes(
951 attribute.String("forkRepo", forkRepo),
952 attribute.String("targetBranch", targetBranch),
953 attribute.String("sourceBranch", sourceBranch),
954 )
955
956 fork, err := db.GetForkByDid(ctx, s.db, user.Did, forkRepo)
957 if errors.Is(err, sql.ErrNoRows) {
958 span.SetAttributes(attribute.String("error", "fork_not_found"))
959 s.pages.Notice(w, "pull", "No such fork.")
960 return
961 } else if err != nil {
962 log.Println("failed to fetch fork:", err)
963 span.RecordError(err)
964 span.SetAttributes(attribute.String("error", "fork_fetch_failed"))
965 s.pages.Notice(w, "pull", "Failed to fetch fork.")
966 return
967 }
968
969 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
970 if err != nil {
971 log.Println("failed to fetch registration key:", err)
972 span.RecordError(err)
973 span.SetAttributes(attribute.String("error", "registration_key_fetch_failed"))
974 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
975 return
976 }
977
978 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
979 if err != nil {
980 log.Println("failed to create signed client:", err)
981 span.RecordError(err)
982 span.SetAttributes(attribute.String("error", "signed_client_creation_failed"))
983 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
984 return
985 }
986
987 us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
988 if err != nil {
989 log.Println("failed to create unsigned client:", err)
990 span.RecordError(err)
991 span.SetAttributes(attribute.String("error", "unsigned_client_creation_failed"))
992 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
993 return
994 }
995
996 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
997 if err != nil {
998 log.Println("failed to create hidden ref:", err, resp.StatusCode)
999 span.RecordError(err)
1000 span.SetAttributes(attribute.String("error", "hidden_ref_creation_failed"))
1001 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1002 return
1003 }
1004
1005 switch resp.StatusCode {
1006 case 404:
1007 span.SetAttributes(attribute.String("error", "not_found_status"))
1008 case 400:
1009 span.SetAttributes(attribute.String("error", "bad_request_status"))
1010 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
1011 return
1012 }
1013
1014 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
1015 span.SetAttributes(attribute.String("hiddenRef", hiddenRef))
1016
1017 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
1018 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
1019 // hiddenRef: hidden/feature-1/main (on repo-fork)
1020 // targetBranch: main (on repo-1)
1021 // sourceBranch: feature-1 (on repo-fork)
1022 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
1023 if err != nil {
1024 log.Println("failed to compare across branches", err)
1025 span.RecordError(err)
1026 span.SetAttributes(attribute.String("error", "branch_comparison_failed"))
1027 s.pages.Notice(w, "pull", err.Error())
1028 return
1029 }
1030
1031 sourceRev := comparison.Rev2
1032 patch := comparison.Patch
1033 span.SetAttributes(attribute.String("sourceRev", sourceRev))
1034
1035 if !patchutil.IsPatchValid(patch) {
1036 span.SetAttributes(attribute.String("error", "invalid_patch_format"))
1037 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1038 return
1039 }
1040
1041 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
1042 if err != nil {
1043 log.Println("failed to parse fork AT URI", err)
1044 span.RecordError(err)
1045 span.SetAttributes(attribute.String("error", "fork_aturi_parse_failed"))
1046 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1047 return
1048 }
1049
1050 s.createPullRequest(w, r.WithContext(ctx), f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
1051 Branch: sourceBranch,
1052 RepoAt: &forkAtUri,
1053 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
1054}
1055
1056func (s *State) createPullRequest(
1057 w http.ResponseWriter,
1058 r *http.Request,
1059 f *FullyResolvedRepo,
1060 user *auth.User,
1061 title, body, targetBranch string,
1062 patch string,
1063 sourceRev string,
1064 pullSource *db.PullSource,
1065 recordPullSource *tangled.RepoPull_Source,
1066) {
1067 ctx, span := s.t.TraceStart(r.Context(), "createPullRequest")
1068 defer span.End()
1069
1070 span.SetAttributes(
1071 attribute.String("targetBranch", targetBranch),
1072 attribute.String("sourceRev", sourceRev),
1073 attribute.Bool("hasPullSource", pullSource != nil),
1074 )
1075
1076 tx, err := s.db.BeginTx(ctx, nil)
1077 if err != nil {
1078 log.Println("failed to start tx")
1079 span.RecordError(err)
1080 span.SetAttributes(attribute.String("error", "transaction_start_failed"))
1081 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1082 return
1083 }
1084 defer tx.Rollback()
1085
1086 // We've already checked earlier if it's diff-based and title is empty,
1087 // so if it's still empty now, it's intentionally skipped owing to format-patch.
1088 if title == "" {
1089 formatPatches, err := patchutil.ExtractPatches(patch)
1090 if err != nil {
1091 span.RecordError(err)
1092 span.SetAttributes(attribute.String("error", "extract_patches_failed"))
1093 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1094 return
1095 }
1096 if len(formatPatches) == 0 {
1097 span.SetAttributes(attribute.String("error", "no_patches_found"))
1098 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
1099 return
1100 }
1101
1102 title = formatPatches[0].Title
1103 body = formatPatches[0].Body
1104 span.SetAttributes(
1105 attribute.Bool("title_extracted", true),
1106 attribute.Bool("body_extracted", formatPatches[0].Body != ""),
1107 )
1108 }
1109
1110 rkey := appview.TID()
1111 initialSubmission := db.PullSubmission{
1112 Patch: patch,
1113 SourceRev: sourceRev,
1114 }
1115 err = db.NewPull(ctx, tx, &db.Pull{
1116 Title: title,
1117 Body: body,
1118 TargetBranch: targetBranch,
1119 OwnerDid: user.Did,
1120 RepoAt: f.RepoAt,
1121 Rkey: rkey,
1122 Submissions: []*db.PullSubmission{
1123 &initialSubmission,
1124 },
1125 PullSource: pullSource,
1126 })
1127 if err != nil {
1128 log.Println("failed to create pull request", err)
1129 span.RecordError(err)
1130 span.SetAttributes(attribute.String("error", "db_create_pull_failed"))
1131 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1132 return
1133 }
1134
1135 client, _ := s.auth.AuthorizedClient(r.WithContext(ctx))
1136 pullId, err := db.NextPullId(s.db, f.RepoAt)
1137 if err != nil {
1138 log.Println("failed to get pull id", err)
1139 span.RecordError(err)
1140 span.SetAttributes(attribute.String("error", "get_pull_id_failed"))
1141 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1142 return
1143 }
1144 span.SetAttributes(attribute.Int("pullId", pullId))
1145
1146 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
1147 Collection: tangled.RepoPullNSID,
1148 Repo: user.Did,
1149 Rkey: rkey,
1150 Record: &lexutil.LexiconTypeDecoder{
1151 Val: &tangled.RepoPull{
1152 Title: title,
1153 PullId: int64(pullId),
1154 TargetRepo: string(f.RepoAt),
1155 TargetBranch: targetBranch,
1156 Patch: patch,
1157 Source: recordPullSource,
1158 },
1159 },
1160 })
1161
1162 if err != nil {
1163 log.Println("failed to create pull request", err)
1164 span.RecordError(err)
1165 span.SetAttributes(attribute.String("error", "atproto_create_record_failed"))
1166 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1167 return
1168 }
1169
1170 if err = tx.Commit(); err != nil {
1171 log.Println("failed to commit transaction", err)
1172 span.RecordError(err)
1173 span.SetAttributes(attribute.String("error", "transaction_commit_failed"))
1174 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1175 return
1176 }
1177
1178 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1179}
1180
1181func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
1182 ctx, span := s.t.TraceStart(r.Context(), "ValidatePatch")
1183 defer span.End()
1184
1185 _, err := s.fullyResolvedRepo(r.WithContext(ctx))
1186 if err != nil {
1187 log.Println("failed to get repo and knot", err)
1188 span.RecordError(err)
1189 span.SetAttributes(attribute.String("error", "resolve_repo_failed"))
1190 return
1191 }
1192
1193 patch := r.FormValue("patch")
1194 span.SetAttributes(attribute.Bool("hasPatch", patch != ""))
1195
1196 if patch == "" {
1197 span.SetAttributes(attribute.String("error", "empty_patch"))
1198 s.pages.Notice(w, "patch-error", "Patch is required.")
1199 return
1200 }
1201
1202 if !patchutil.IsPatchValid(patch) {
1203 span.SetAttributes(attribute.String("error", "invalid_patch_format"))
1204 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1205 return
1206 }
1207
1208 isFormatPatch := patchutil.IsFormatPatch(patch)
1209 span.SetAttributes(attribute.Bool("isFormatPatch", isFormatPatch))
1210
1211 if isFormatPatch {
1212 s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
1213 } else {
1214 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
1215 }
1216}
1217
1218func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1219 ctx, span := s.t.TraceStart(r.Context(), "PatchUploadFragment")
1220 defer span.End()
1221
1222 user := s.auth.GetUser(r.WithContext(ctx))
1223 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1224 if err != nil {
1225 log.Println("failed to get repo and knot", err)
1226 span.RecordError(err)
1227 span.SetAttributes(attribute.String("error", "resolve_repo_failed"))
1228 return
1229 }
1230
1231 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1232 RepoInfo: f.RepoInfo(ctx, s, user),
1233 })
1234}
1235
1236func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
1237 ctx, span := s.t.TraceStart(r.Context(), "CompareBranchesFragment")
1238 defer span.End()
1239
1240 user := s.auth.GetUser(r.WithContext(ctx))
1241 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1242 if err != nil {
1243 log.Println("failed to get repo and knot", err)
1244 span.RecordError(err)
1245 span.SetAttributes(attribute.String("error", "resolve_repo_failed"))
1246 return
1247 }
1248
1249 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
1250 if err != nil {
1251 log.Printf("failed to create unsigned client for %s", f.Knot)
1252 span.RecordError(err)
1253 span.SetAttributes(attribute.String("error", "client_creation_failed"))
1254 s.pages.Error503(w)
1255 return
1256 }
1257
1258 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1259 if err != nil {
1260 log.Println("failed to reach knotserver", err)
1261 span.RecordError(err)
1262 span.SetAttributes(attribute.String("error", "knotserver_connection_failed"))
1263 return
1264 }
1265
1266 body, err := io.ReadAll(resp.Body)
1267 if err != nil {
1268 log.Printf("Error reading response body: %v", err)
1269 span.RecordError(err)
1270 span.SetAttributes(attribute.String("error", "response_read_failed"))
1271 return
1272 }
1273 defer resp.Body.Close()
1274
1275 var result types.RepoBranchesResponse
1276 err = json.Unmarshal(body, &result)
1277 if err != nil {
1278 log.Println("failed to parse response:", err)
1279 span.RecordError(err)
1280 span.SetAttributes(attribute.String("error", "response_parse_failed"))
1281 return
1282 }
1283 span.SetAttributes(attribute.Int("branches.count", len(result.Branches)))
1284
1285 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1286 RepoInfo: f.RepoInfo(ctx, s, user),
1287 Branches: result.Branches,
1288 })
1289}
1290
1291func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1292 ctx, span := s.t.TraceStart(r.Context(), "CompareForksFragment")
1293 defer span.End()
1294
1295 user := s.auth.GetUser(r.WithContext(ctx))
1296 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1297 if err != nil {
1298 log.Println("failed to get repo and knot", err)
1299 span.RecordError(err)
1300 return
1301 }
1302
1303 forks, err := db.GetForksByDid(ctx, s.db, user.Did)
1304 if err != nil {
1305 log.Println("failed to get forks", err)
1306 span.RecordError(err)
1307 return
1308 }
1309
1310 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1311 RepoInfo: f.RepoInfo(ctx, s, user),
1312 Forks: forks,
1313 })
1314}
1315
1316func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1317 ctx, span := s.t.TraceStart(r.Context(), "CompareForksBranchesFragment")
1318 defer span.End()
1319
1320 user := s.auth.GetUser(r.WithContext(ctx))
1321
1322 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1323 if err != nil {
1324 log.Println("failed to get repo and knot", err)
1325 span.RecordError(err)
1326 return
1327 }
1328
1329 forkVal := r.URL.Query().Get("fork")
1330 span.SetAttributes(attribute.String("fork", forkVal))
1331
1332 // fork repo
1333 repo, err := db.GetRepo(ctx, s.db, user.Did, forkVal)
1334 if err != nil {
1335 log.Println("failed to get repo", user.Did, forkVal)
1336 span.RecordError(err)
1337 return
1338 }
1339
1340 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
1341 if err != nil {
1342 log.Printf("failed to create unsigned client for %s", repo.Knot)
1343 span.RecordError(err)
1344 s.pages.Error503(w)
1345 return
1346 }
1347
1348 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1349 if err != nil {
1350 log.Println("failed to reach knotserver for source branches", err)
1351 span.RecordError(err)
1352 return
1353 }
1354
1355 sourceBody, err := io.ReadAll(sourceResp.Body)
1356 if err != nil {
1357 log.Println("failed to read source response body", err)
1358 span.RecordError(err)
1359 return
1360 }
1361 defer sourceResp.Body.Close()
1362
1363 var sourceResult types.RepoBranchesResponse
1364 err = json.Unmarshal(sourceBody, &sourceResult)
1365 if err != nil {
1366 log.Println("failed to parse source branches response:", err)
1367 span.RecordError(err)
1368 return
1369 }
1370
1371 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1372 if err != nil {
1373 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1374 span.RecordError(err)
1375 s.pages.Error503(w)
1376 return
1377 }
1378
1379 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1380 if err != nil {
1381 log.Println("failed to reach knotserver for target branches", err)
1382 span.RecordError(err)
1383 return
1384 }
1385
1386 targetBody, err := io.ReadAll(targetResp.Body)
1387 if err != nil {
1388 log.Println("failed to read target response body", err)
1389 span.RecordError(err)
1390 return
1391 }
1392 defer targetResp.Body.Close()
1393
1394 var targetResult types.RepoBranchesResponse
1395 err = json.Unmarshal(targetBody, &targetResult)
1396 if err != nil {
1397 log.Println("failed to parse target branches response:", err)
1398 span.RecordError(err)
1399 return
1400 }
1401
1402 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1403 RepoInfo: f.RepoInfo(ctx, s, user),
1404 SourceBranches: sourceResult.Branches,
1405 TargetBranches: targetResult.Branches,
1406 })
1407}
1408
1409func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1410 ctx, span := s.t.TraceStart(r.Context(), "ResubmitPull")
1411 defer span.End()
1412
1413 user := s.auth.GetUser(r.WithContext(ctx))
1414 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1415 if err != nil {
1416 log.Println("failed to get repo and knot", err)
1417 span.RecordError(err)
1418 return
1419 }
1420
1421 pull, ok := ctx.Value("pull").(*db.Pull)
1422 if !ok {
1423 log.Println("failed to get pull")
1424 span.RecordError(errors.New("failed to get pull from context"))
1425 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1426 return
1427 }
1428
1429 span.SetAttributes(
1430 attribute.Int("pull.id", pull.PullId),
1431 attribute.String("pull.owner", pull.OwnerDid),
1432 attribute.String("method", r.Method),
1433 )
1434
1435 switch r.Method {
1436 case http.MethodGet:
1437 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1438 RepoInfo: f.RepoInfo(ctx, s, user),
1439 Pull: pull,
1440 })
1441 return
1442 case http.MethodPost:
1443 if pull.IsPatchBased() {
1444 span.SetAttributes(attribute.String("pull.type", "patch_based"))
1445 s.resubmitPatch(w, r.WithContext(ctx))
1446 return
1447 } else if pull.IsBranchBased() {
1448 span.SetAttributes(attribute.String("pull.type", "branch_based"))
1449 s.resubmitBranch(w, r.WithContext(ctx))
1450 return
1451 } else if pull.IsForkBased() {
1452 span.SetAttributes(attribute.String("pull.type", "fork_based"))
1453 s.resubmitFork(w, r.WithContext(ctx))
1454 return
1455 }
1456 span.SetAttributes(attribute.String("pull.type", "unknown"))
1457 }
1458}
1459
1460func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1461 ctx, span := s.t.TraceStart(r.Context(), "resubmitPatch")
1462 defer span.End()
1463
1464 user := s.auth.GetUser(r.WithContext(ctx))
1465
1466 pull, ok := ctx.Value("pull").(*db.Pull)
1467 if !ok {
1468 log.Println("failed to get pull")
1469 span.RecordError(errors.New("failed to get pull from context"))
1470 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1471 return
1472 }
1473
1474 span.SetAttributes(
1475 attribute.Int("pull.id", pull.PullId),
1476 attribute.String("pull.owner", pull.OwnerDid),
1477 )
1478
1479 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1480 if err != nil {
1481 log.Println("failed to get repo and knot", err)
1482 span.RecordError(err)
1483 return
1484 }
1485
1486 if user.Did != pull.OwnerDid {
1487 log.Println("unauthorized user")
1488 span.SetAttributes(attribute.String("error", "unauthorized_user"))
1489 w.WriteHeader(http.StatusUnauthorized)
1490 return
1491 }
1492
1493 patch := r.FormValue("patch")
1494 span.SetAttributes(attribute.Bool("has_patch", patch != ""))
1495
1496 if err = validateResubmittedPatch(pull, patch); err != nil {
1497 span.SetAttributes(attribute.String("error", "invalid_patch"))
1498 s.pages.Notice(w, "resubmit-error", err.Error())
1499 return
1500 }
1501
1502 tx, err := s.db.BeginTx(ctx, nil)
1503 if err != nil {
1504 log.Println("failed to start tx")
1505 span.RecordError(err)
1506 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1507 return
1508 }
1509 defer tx.Rollback()
1510
1511 err = db.ResubmitPull(tx, pull, patch, "")
1512 if err != nil {
1513 log.Println("failed to resubmit pull request", err)
1514 span.RecordError(err)
1515 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1516 return
1517 }
1518 client, _ := s.auth.AuthorizedClient(r.WithContext(ctx))
1519
1520 ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1521 if err != nil {
1522 // failed to get record
1523 span.RecordError(err)
1524 span.SetAttributes(attribute.String("error", "record_not_found"))
1525 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1526 return
1527 }
1528
1529 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
1530 Collection: tangled.RepoPullNSID,
1531 Repo: user.Did,
1532 Rkey: pull.Rkey,
1533 SwapRecord: ex.Cid,
1534 Record: &lexutil.LexiconTypeDecoder{
1535 Val: &tangled.RepoPull{
1536 Title: pull.Title,
1537 PullId: int64(pull.PullId),
1538 TargetRepo: string(f.RepoAt),
1539 TargetBranch: pull.TargetBranch,
1540 Patch: patch, // new patch
1541 },
1542 },
1543 })
1544 if err != nil {
1545 log.Println("failed to update record", err)
1546 span.RecordError(err)
1547 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1548 return
1549 }
1550
1551 if err = tx.Commit(); err != nil {
1552 log.Println("failed to commit transaction", err)
1553 span.RecordError(err)
1554 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1555 return
1556 }
1557
1558 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1559 return
1560}
1561
1562func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1563 ctx, span := s.t.TraceStart(r.Context(), "resubmitBranch")
1564 defer span.End()
1565
1566 user := s.auth.GetUser(r.WithContext(ctx))
1567
1568 pull, ok := ctx.Value("pull").(*db.Pull)
1569 if !ok {
1570 log.Println("failed to get pull")
1571 span.RecordError(errors.New("failed to get pull from context"))
1572 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1573 return
1574 }
1575
1576 span.SetAttributes(
1577 attribute.Int("pull.id", pull.PullId),
1578 attribute.String("pull.owner", pull.OwnerDid),
1579 attribute.String("pull.source_branch", pull.PullSource.Branch),
1580 attribute.String("pull.target_branch", pull.TargetBranch),
1581 )
1582
1583 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1584 if err != nil {
1585 log.Println("failed to get repo and knot", err)
1586 span.RecordError(err)
1587 return
1588 }
1589
1590 if user.Did != pull.OwnerDid {
1591 log.Println("unauthorized user")
1592 span.SetAttributes(attribute.String("error", "unauthorized_user"))
1593 w.WriteHeader(http.StatusUnauthorized)
1594 return
1595 }
1596
1597 if !f.RepoInfo(ctx, s, user).Roles.IsPushAllowed() {
1598 log.Println("unauthorized user")
1599 span.SetAttributes(attribute.String("error", "push_not_allowed"))
1600 w.WriteHeader(http.StatusUnauthorized)
1601 return
1602 }
1603
1604 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1605 if err != nil {
1606 log.Printf("failed to create client for %s: %s", f.Knot, err)
1607 span.RecordError(err)
1608 span.SetAttributes(attribute.String("error", "client_creation_failed"))
1609 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1610 return
1611 }
1612
1613 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1614 if err != nil {
1615 log.Printf("compare request failed: %s", err)
1616 span.RecordError(err)
1617 span.SetAttributes(attribute.String("error", "compare_failed"))
1618 s.pages.Notice(w, "resubmit-error", err.Error())
1619 return
1620 }
1621
1622 sourceRev := comparison.Rev2
1623 patch := comparison.Patch
1624 span.SetAttributes(attribute.String("source_rev", sourceRev))
1625
1626 if err = validateResubmittedPatch(pull, patch); err != nil {
1627 span.SetAttributes(attribute.String("error", "invalid_patch"))
1628 s.pages.Notice(w, "resubmit-error", err.Error())
1629 return
1630 }
1631
1632 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1633 span.SetAttributes(attribute.String("error", "no_changes"))
1634 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1635 return
1636 }
1637
1638 tx, err := s.db.BeginTx(ctx, nil)
1639 if err != nil {
1640 log.Println("failed to start tx")
1641 span.RecordError(err)
1642 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1643 return
1644 }
1645 defer tx.Rollback()
1646
1647 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1648 if err != nil {
1649 log.Println("failed to create pull request", err)
1650 span.RecordError(err)
1651 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1652 return
1653 }
1654 client, _ := s.auth.AuthorizedClient(r.WithContext(ctx))
1655
1656 ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1657 if err != nil {
1658 // failed to get record
1659 span.RecordError(err)
1660 span.SetAttributes(attribute.String("error", "record_not_found"))
1661 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1662 return
1663 }
1664
1665 recordPullSource := &tangled.RepoPull_Source{
1666 Branch: pull.PullSource.Branch,
1667 }
1668 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
1669 Collection: tangled.RepoPullNSID,
1670 Repo: user.Did,
1671 Rkey: pull.Rkey,
1672 SwapRecord: ex.Cid,
1673 Record: &lexutil.LexiconTypeDecoder{
1674 Val: &tangled.RepoPull{
1675 Title: pull.Title,
1676 PullId: int64(pull.PullId),
1677 TargetRepo: string(f.RepoAt),
1678 TargetBranch: pull.TargetBranch,
1679 Patch: patch, // new patch
1680 Source: recordPullSource,
1681 },
1682 },
1683 })
1684 if err != nil {
1685 log.Println("failed to update record", err)
1686 span.RecordError(err)
1687 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1688 return
1689 }
1690
1691 if err = tx.Commit(); err != nil {
1692 log.Println("failed to commit transaction", err)
1693 span.RecordError(err)
1694 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1695 return
1696 }
1697
1698 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1699 return
1700}
1701
1702func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1703 ctx, span := s.t.TraceStart(r.Context(), "resubmitFork")
1704 defer span.End()
1705
1706 user := s.auth.GetUser(r.WithContext(ctx))
1707
1708 pull, ok := ctx.Value("pull").(*db.Pull)
1709 if !ok {
1710 log.Println("failed to get pull")
1711 span.RecordError(errors.New("failed to get pull from context"))
1712 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1713 return
1714 }
1715
1716 span.SetAttributes(
1717 attribute.Int("pull.id", pull.PullId),
1718 attribute.String("pull.owner", pull.OwnerDid),
1719 attribute.String("pull.source_branch", pull.PullSource.Branch),
1720 attribute.String("pull.target_branch", pull.TargetBranch),
1721 )
1722
1723 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1724 if err != nil {
1725 log.Println("failed to get repo and knot", err)
1726 span.RecordError(err)
1727 return
1728 }
1729
1730 if user.Did != pull.OwnerDid {
1731 log.Println("unauthorized user")
1732 span.SetAttributes(attribute.String("error", "unauthorized_user"))
1733 w.WriteHeader(http.StatusUnauthorized)
1734 return
1735 }
1736
1737 forkRepo, err := db.GetRepoByAtUri(ctx, s.db, pull.PullSource.RepoAt.String())
1738 if err != nil {
1739 log.Println("failed to get source repo", err)
1740 span.RecordError(err)
1741 span.SetAttributes(attribute.String("error", "source_repo_not_found"))
1742 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1743 return
1744 }
1745
1746 span.SetAttributes(
1747 attribute.String("fork.knot", forkRepo.Knot),
1748 attribute.String("fork.did", forkRepo.Did),
1749 attribute.String("fork.name", forkRepo.Name),
1750 )
1751
1752 // extract patch by performing compare
1753 ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
1754 if err != nil {
1755 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1756 span.RecordError(err)
1757 span.SetAttributes(attribute.String("error", "client_creation_failed"))
1758 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1759 return
1760 }
1761
1762 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1763 if err != nil {
1764 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1765 span.RecordError(err)
1766 span.SetAttributes(attribute.String("error", "reg_key_not_found"))
1767 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1768 return
1769 }
1770
1771 // update the hidden tracking branch to latest
1772 signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
1773 if err != nil {
1774 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1775 span.RecordError(err)
1776 span.SetAttributes(attribute.String("error", "signed_client_creation_failed"))
1777 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1778 return
1779 }
1780
1781 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1782 if err != nil || resp.StatusCode != http.StatusNoContent {
1783 log.Printf("failed to update tracking branch: %s", err)
1784 span.RecordError(err)
1785 span.SetAttributes(attribute.String("error", "hidden_ref_update_failed"))
1786 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1787 return
1788 }
1789
1790 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1791 span.SetAttributes(attribute.String("hidden_ref", hiddenRef))
1792
1793 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1794 if err != nil {
1795 log.Printf("failed to compare branches: %s", err)
1796 span.RecordError(err)
1797 span.SetAttributes(attribute.String("error", "compare_failed"))
1798 s.pages.Notice(w, "resubmit-error", err.Error())
1799 return
1800 }
1801
1802 sourceRev := comparison.Rev2
1803 patch := comparison.Patch
1804 span.SetAttributes(attribute.String("source_rev", sourceRev))
1805
1806 if err = validateResubmittedPatch(pull, patch); err != nil {
1807 span.SetAttributes(attribute.String("error", "invalid_patch"))
1808 s.pages.Notice(w, "resubmit-error", err.Error())
1809 return
1810 }
1811
1812 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1813 span.SetAttributes(attribute.String("error", "no_changes"))
1814 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1815 return
1816 }
1817
1818 tx, err := s.db.BeginTx(ctx, nil)
1819 if err != nil {
1820 log.Println("failed to start tx")
1821 span.RecordError(err)
1822 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1823 return
1824 }
1825 defer tx.Rollback()
1826
1827 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1828 if err != nil {
1829 log.Println("failed to create pull request", err)
1830 span.RecordError(err)
1831 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1832 return
1833 }
1834 client, _ := s.auth.AuthorizedClient(r.WithContext(ctx))
1835
1836 ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1837 if err != nil {
1838 // failed to get record
1839 span.RecordError(err)
1840 span.SetAttributes(attribute.String("error", "record_not_found"))
1841 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1842 return
1843 }
1844
1845 repoAt := pull.PullSource.RepoAt.String()
1846 recordPullSource := &tangled.RepoPull_Source{
1847 Branch: pull.PullSource.Branch,
1848 Repo: &repoAt,
1849 }
1850 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
1851 Collection: tangled.RepoPullNSID,
1852 Repo: user.Did,
1853 Rkey: pull.Rkey,
1854 SwapRecord: ex.Cid,
1855 Record: &lexutil.LexiconTypeDecoder{
1856 Val: &tangled.RepoPull{
1857 Title: pull.Title,
1858 PullId: int64(pull.PullId),
1859 TargetRepo: string(f.RepoAt),
1860 TargetBranch: pull.TargetBranch,
1861 Patch: patch, // new patch
1862 Source: recordPullSource,
1863 },
1864 },
1865 })
1866 if err != nil {
1867 log.Println("failed to update record", err)
1868 span.RecordError(err)
1869 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1870 return
1871 }
1872
1873 if err = tx.Commit(); err != nil {
1874 log.Println("failed to commit transaction", err)
1875 span.RecordError(err)
1876 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1877 return
1878 }
1879
1880 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1881 return
1882}
1883
1884// validate a resubmission against a pull request
1885func validateResubmittedPatch(pull *db.Pull, patch string) error {
1886 if patch == "" {
1887 return fmt.Errorf("Patch is empty.")
1888 }
1889
1890 if patch == pull.LatestPatch() {
1891 return fmt.Errorf("Patch is identical to previous submission.")
1892 }
1893
1894 if !patchutil.IsPatchValid(patch) {
1895 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1896 }
1897
1898 return nil
1899}
1900
1901func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1902 ctx, span := s.t.TraceStart(r.Context(), "MergePull")
1903 defer span.End()
1904
1905 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1906 if err != nil {
1907 log.Println("failed to resolve repo:", err)
1908 span.RecordError(err)
1909 span.SetAttributes(attribute.String("error", "resolve_repo_failed"))
1910 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1911 return
1912 }
1913
1914 pull, ok := ctx.Value("pull").(*db.Pull)
1915 if !ok {
1916 log.Println("failed to get pull")
1917 span.SetAttributes(attribute.String("error", "pull_not_in_context"))
1918 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1919 return
1920 }
1921
1922 span.SetAttributes(
1923 attribute.Int("pull.id", pull.PullId),
1924 attribute.String("pull.owner", pull.OwnerDid),
1925 attribute.String("target_branch", pull.TargetBranch),
1926 )
1927
1928 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1929 if err != nil {
1930 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1931 span.RecordError(err)
1932 span.SetAttributes(attribute.String("error", "reg_key_not_found"))
1933 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1934 return
1935 }
1936
1937 ident, err := s.resolver.ResolveIdent(ctx, pull.OwnerDid)
1938 if err != nil {
1939 log.Printf("resolving identity: %s", err)
1940 span.RecordError(err)
1941 span.SetAttributes(attribute.String("error", "resolve_identity_failed"))
1942 w.WriteHeader(http.StatusNotFound)
1943 return
1944 }
1945
1946 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1947 if err != nil {
1948 log.Printf("failed to get primary email: %s", err)
1949 span.RecordError(err)
1950 span.SetAttributes(attribute.String("error", "get_email_failed"))
1951 }
1952
1953 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1954 if err != nil {
1955 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1956 span.RecordError(err)
1957 span.SetAttributes(attribute.String("error", "client_creation_failed"))
1958 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1959 return
1960 }
1961
1962 // Merge the pull request
1963 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1964 if err != nil {
1965 log.Printf("failed to merge pull request: %s", err)
1966 span.RecordError(err)
1967 span.SetAttributes(attribute.String("error", "merge_failed"))
1968 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1969 return
1970 }
1971
1972 span.SetAttributes(attribute.Int("response.status", resp.StatusCode))
1973
1974 if resp.StatusCode == http.StatusOK {
1975 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1976 if err != nil {
1977 log.Printf("failed to update pull request status in database: %s", err)
1978 span.RecordError(err)
1979 span.SetAttributes(attribute.String("error", "db_update_failed"))
1980 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1981 return
1982 }
1983 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1984 } else {
1985 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1986 span.SetAttributes(attribute.String("error", "non_ok_response"))
1987 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1988 }
1989}
1990
1991func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1992 ctx, span := s.t.TraceStart(r.Context(), "ClosePull")
1993 defer span.End()
1994
1995 user := s.auth.GetUser(r.WithContext(ctx))
1996
1997 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
1998 if err != nil {
1999 log.Println("malformed middleware")
2000 span.RecordError(err)
2001 span.SetAttributes(attribute.String("error", "resolve_repo_failed"))
2002 return
2003 }
2004
2005 pull, ok := ctx.Value("pull").(*db.Pull)
2006 if !ok {
2007 log.Println("failed to get pull")
2008 span.SetAttributes(attribute.String("error", "pull_not_in_context"))
2009 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2010 return
2011 }
2012
2013 span.SetAttributes(
2014 attribute.Int("pull.id", pull.PullId),
2015 attribute.String("pull.owner", pull.OwnerDid),
2016 attribute.String("user.did", user.Did),
2017 )
2018
2019 // auth filter: only owner or collaborators can close
2020 roles := RolesInRepo(s, user, f)
2021 isCollaborator := roles.IsCollaborator()
2022 isPullAuthor := user.Did == pull.OwnerDid
2023 isCloseAllowed := isCollaborator || isPullAuthor
2024
2025 span.SetAttributes(
2026 attribute.Bool("is_collaborator", isCollaborator),
2027 attribute.Bool("is_pull_author", isPullAuthor),
2028 attribute.Bool("is_close_allowed", isCloseAllowed),
2029 )
2030
2031 if !isCloseAllowed {
2032 log.Println("failed to close pull")
2033 span.SetAttributes(attribute.String("error", "unauthorized"))
2034 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2035 return
2036 }
2037
2038 // Start a transaction
2039 tx, err := s.db.BeginTx(ctx, nil)
2040 if err != nil {
2041 log.Println("failed to start transaction", err)
2042 span.RecordError(err)
2043 span.SetAttributes(attribute.String("error", "transaction_start_failed"))
2044 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2045 return
2046 }
2047
2048 // Close the pull in the database
2049 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
2050 if err != nil {
2051 log.Println("failed to close pull", err)
2052 span.RecordError(err)
2053 span.SetAttributes(attribute.String("error", "db_close_failed"))
2054 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2055 return
2056 }
2057
2058 // Commit the transaction
2059 if err = tx.Commit(); err != nil {
2060 log.Println("failed to commit transaction", err)
2061 span.RecordError(err)
2062 span.SetAttributes(attribute.String("error", "transaction_commit_failed"))
2063 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2064 return
2065 }
2066
2067 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2068 return
2069}
2070
2071func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
2072 ctx, span := s.t.TraceStart(r.Context(), "ReopenPull")
2073 defer span.End()
2074
2075 user := s.auth.GetUser(r.WithContext(ctx))
2076
2077 f, err := s.fullyResolvedRepo(r.WithContext(ctx))
2078 if err != nil {
2079 log.Println("failed to resolve repo", err)
2080 span.RecordError(err)
2081 span.SetAttributes(attribute.String("error", "resolve_repo_failed"))
2082 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2083 return
2084 }
2085
2086 pull, ok := ctx.Value("pull").(*db.Pull)
2087 if !ok {
2088 log.Println("failed to get pull")
2089 span.SetAttributes(attribute.String("error", "pull_not_in_context"))
2090 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2091 return
2092 }
2093
2094 span.SetAttributes(
2095 attribute.Int("pull.id", pull.PullId),
2096 attribute.String("pull.owner", pull.OwnerDid),
2097 attribute.String("user.did", user.Did),
2098 )
2099
2100 // auth filter: only owner or collaborators can reopen
2101 roles := RolesInRepo(s, user, f)
2102 isCollaborator := roles.IsCollaborator()
2103 isPullAuthor := user.Did == pull.OwnerDid
2104 isReopenAllowed := isCollaborator || isPullAuthor
2105
2106 span.SetAttributes(
2107 attribute.Bool("is_collaborator", isCollaborator),
2108 attribute.Bool("is_pull_author", isPullAuthor),
2109 attribute.Bool("is_reopen_allowed", isReopenAllowed),
2110 )
2111
2112 if !isReopenAllowed {
2113 log.Println("failed to reopen pull")
2114 span.SetAttributes(attribute.String("error", "unauthorized"))
2115 s.pages.Notice(w, "pull-close", "You are unauthorized to reopen this pull.")
2116 return
2117 }
2118
2119 // Start a transaction
2120 tx, err := s.db.BeginTx(ctx, nil)
2121 if err != nil {
2122 log.Println("failed to start transaction", err)
2123 span.RecordError(err)
2124 span.SetAttributes(attribute.String("error", "transaction_start_failed"))
2125 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2126 return
2127 }
2128
2129 // Reopen the pull in the database
2130 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
2131 if err != nil {
2132 log.Println("failed to reopen pull", err)
2133 span.RecordError(err)
2134 span.SetAttributes(attribute.String("error", "db_reopen_failed"))
2135 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2136 return
2137 }
2138
2139 // Commit the transaction
2140 if err = tx.Commit(); err != nil {
2141 log.Println("failed to commit transaction", err)
2142 span.RecordError(err)
2143 span.SetAttributes(attribute.String("error", "transaction_commit_failed"))
2144 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2145 return
2146 }
2147
2148 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2149 return
2150}