1package state
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/go-chi/chi/v5"
17 "tangled.sh/tangled.sh/core/api/tangled"
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/types"
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
28// htmx fragment
29func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
30 switch r.Method {
31 case http.MethodGet:
32 user := s.auth.GetUser(r)
33 f, err := fullyResolvedRepo(r)
34 if err != nil {
35 log.Println("failed to get repo and knot", err)
36 return
37 }
38
39 pull, ok := r.Context().Value("pull").(*db.Pull)
40 if !ok {
41 log.Println("failed to get pull")
42 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
43 return
44 }
45
46 roundNumberStr := chi.URLParam(r, "round")
47 roundNumber, err := strconv.Atoi(roundNumberStr)
48 if err != nil {
49 roundNumber = pull.LastRoundNumber()
50 }
51 if roundNumber >= len(pull.Submissions) {
52 http.Error(w, "bad round id", http.StatusBadRequest)
53 log.Println("failed to parse round id", err)
54 return
55 }
56
57 mergeCheckResponse := s.mergeCheck(f, pull)
58 var resubmitResult pages.ResubmitResult
59 if user.Did == pull.OwnerDid {
60 resubmitResult = s.resubmitCheck(f, pull)
61 }
62
63 s.pages.PullActionsFragment(w, pages.PullActionsParams{
64 LoggedInUser: user,
65 RepoInfo: f.RepoInfo(s, user),
66 Pull: pull,
67 RoundNumber: roundNumber,
68 MergeCheck: mergeCheckResponse,
69 ResubmitCheck: resubmitResult,
70 })
71 return
72 }
73}
74
75func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
76 user := s.auth.GetUser(r)
77 f, err := fullyResolvedRepo(r)
78 if err != nil {
79 log.Println("failed to get repo and knot", err)
80 return
81 }
82
83 pull, ok := r.Context().Value("pull").(*db.Pull)
84 if !ok {
85 log.Println("failed to get pull")
86 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
87 return
88 }
89
90 totalIdents := 1
91 for _, submission := range pull.Submissions {
92 totalIdents += len(submission.Comments)
93 }
94
95 identsToResolve := make([]string, totalIdents)
96
97 // populate idents
98 identsToResolve[0] = pull.OwnerDid
99 idx := 1
100 for _, submission := range pull.Submissions {
101 for _, comment := range submission.Comments {
102 identsToResolve[idx] = comment.OwnerDid
103 idx += 1
104 }
105 }
106
107 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
108 didHandleMap := make(map[string]string)
109 for _, identity := range resolvedIds {
110 if !identity.Handle.IsInvalidHandle() {
111 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
112 } else {
113 didHandleMap[identity.DID.String()] = identity.DID.String()
114 }
115 }
116
117 mergeCheckResponse := s.mergeCheck(f, pull)
118 var resubmitResult pages.ResubmitResult
119 if user.Did == pull.OwnerDid {
120 resubmitResult = s.resubmitCheck(f, pull)
121 }
122
123 var pullSourceRepo *db.Repo
124 if pull.PullSource != nil {
125 if pull.PullSource.RepoAt != nil {
126 pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
127 if err != nil {
128 log.Printf("failed to get repo by at uri: %v", err)
129 return
130 }
131 }
132 }
133
134 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
135 LoggedInUser: user,
136 RepoInfo: f.RepoInfo(s, user),
137 DidHandleMap: didHandleMap,
138 Pull: pull,
139 PullSourceRepo: pullSourceRepo,
140 MergeCheck: mergeCheckResponse,
141 ResubmitCheck: resubmitResult,
142 })
143}
144
145func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse {
146 if pull.State == db.PullMerged {
147 return types.MergeCheckResponse{}
148 }
149
150 secret, err := db.GetRegistrationKey(s.db, f.Knot)
151 if err != nil {
152 log.Printf("failed to get registration key: %v", err)
153 return types.MergeCheckResponse{
154 Error: "failed to check merge status: this knot is unregistered",
155 }
156 }
157
158 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
159 if err != nil {
160 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
161 return types.MergeCheckResponse{
162 Error: "failed to check merge status",
163 }
164 }
165
166 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch)
167 if err != nil {
168 log.Println("failed to check for mergeability:", err)
169 return types.MergeCheckResponse{
170 Error: "failed to check merge status",
171 }
172 }
173 switch resp.StatusCode {
174 case 404:
175 return types.MergeCheckResponse{
176 Error: "failed to check merge status: this knot does not support PRs",
177 }
178 case 400:
179 return types.MergeCheckResponse{
180 Error: "failed to check merge status: does this knot support PRs?",
181 }
182 }
183
184 respBody, err := io.ReadAll(resp.Body)
185 if err != nil {
186 log.Println("failed to read merge check response body")
187 return types.MergeCheckResponse{
188 Error: "failed to check merge status: knot is not speaking the right language",
189 }
190 }
191 defer resp.Body.Close()
192
193 var mergeCheckResponse types.MergeCheckResponse
194 err = json.Unmarshal(respBody, &mergeCheckResponse)
195 if err != nil {
196 log.Println("failed to unmarshal merge check response", err)
197 return types.MergeCheckResponse{
198 Error: "failed to check merge status: knot is not speaking the right language",
199 }
200 }
201
202 return mergeCheckResponse
203}
204
205func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
206 if pull.State == db.PullMerged || pull.PullSource == nil {
207 return pages.Unknown
208 }
209
210 var knot, ownerDid, repoName string
211
212 if pull.PullSource.RepoAt != nil {
213 // fork-based pulls
214 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
215 if err != nil {
216 log.Println("failed to get source repo", err)
217 return pages.Unknown
218 }
219
220 knot = sourceRepo.Knot
221 ownerDid = sourceRepo.Did
222 repoName = sourceRepo.Name
223 } else {
224 // pulls within the same repo
225 knot = f.Knot
226 ownerDid = f.OwnerDid()
227 repoName = f.RepoName
228 }
229
230 us, err := NewUnsignedClient(knot, s.config.Dev)
231 if err != nil {
232 log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
233 return pages.Unknown
234 }
235
236 resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
237 if err != nil {
238 log.Println("failed to reach knotserver", err)
239 return pages.Unknown
240 }
241
242 body, err := io.ReadAll(resp.Body)
243 if err != nil {
244 log.Printf("error reading response body: %v", err)
245 return pages.Unknown
246 }
247 defer resp.Body.Close()
248
249 var result types.RepoBranchResponse
250 if err := json.Unmarshal(body, &result); err != nil {
251 log.Println("failed to parse response:", err)
252 return pages.Unknown
253 }
254
255 latestSubmission := pull.Submissions[pull.LastRoundNumber()]
256 if latestSubmission.SourceRev != result.Branch.Hash {
257 return pages.ShouldResubmit
258 }
259
260 return pages.ShouldNotResubmit
261}
262
263func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
264 user := s.auth.GetUser(r)
265 f, err := fullyResolvedRepo(r)
266 if err != nil {
267 log.Println("failed to get repo and knot", err)
268 return
269 }
270
271 pull, ok := r.Context().Value("pull").(*db.Pull)
272 if !ok {
273 log.Println("failed to get pull")
274 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
275 return
276 }
277
278 roundId := chi.URLParam(r, "round")
279 roundIdInt, err := strconv.Atoi(roundId)
280 if err != nil || roundIdInt >= len(pull.Submissions) {
281 http.Error(w, "bad round id", http.StatusBadRequest)
282 log.Println("failed to parse round id", err)
283 return
284 }
285
286 identsToResolve := []string{pull.OwnerDid}
287 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
288 didHandleMap := make(map[string]string)
289 for _, identity := range resolvedIds {
290 if !identity.Handle.IsInvalidHandle() {
291 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
292 } else {
293 didHandleMap[identity.DID.String()] = identity.DID.String()
294 }
295 }
296
297 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
298 LoggedInUser: user,
299 DidHandleMap: didHandleMap,
300 RepoInfo: f.RepoInfo(s, user),
301 Pull: pull,
302 Round: roundIdInt,
303 Submission: pull.Submissions[roundIdInt],
304 Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
305 })
306
307}
308
309func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
310 pull, ok := r.Context().Value("pull").(*db.Pull)
311 if !ok {
312 log.Println("failed to get pull")
313 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
314 return
315 }
316
317 roundId := chi.URLParam(r, "round")
318 roundIdInt, err := strconv.Atoi(roundId)
319 if err != nil || roundIdInt >= len(pull.Submissions) {
320 http.Error(w, "bad round id", http.StatusBadRequest)
321 log.Println("failed to parse round id", err)
322 return
323 }
324
325 identsToResolve := []string{pull.OwnerDid}
326 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
327 didHandleMap := make(map[string]string)
328 for _, identity := range resolvedIds {
329 if !identity.Handle.IsInvalidHandle() {
330 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
331 } else {
332 didHandleMap[identity.DID.String()] = identity.DID.String()
333 }
334 }
335
336 w.Header().Set("Content-Type", "text/plain")
337 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
338}
339
340func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
341 user := s.auth.GetUser(r)
342 params := r.URL.Query()
343
344 state := db.PullOpen
345 switch params.Get("state") {
346 case "closed":
347 state = db.PullClosed
348 case "merged":
349 state = db.PullMerged
350 }
351
352 f, err := fullyResolvedRepo(r)
353 if err != nil {
354 log.Println("failed to get repo and knot", err)
355 return
356 }
357
358 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
359 if err != nil {
360 log.Println("failed to get pulls", err)
361 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
362 return
363 }
364
365 for _, p := range pulls {
366 var pullSourceRepo *db.Repo
367 if p.PullSource != nil {
368 if p.PullSource.RepoAt != nil {
369 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
370 if err != nil {
371 log.Printf("failed to get repo by at uri: %v", err)
372 return
373 }
374 }
375 }
376
377 p.PullSource.Repo = pullSourceRepo
378 }
379
380 identsToResolve := make([]string, len(pulls))
381 for i, pull := range pulls {
382 identsToResolve[i] = pull.OwnerDid
383 }
384 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
385 didHandleMap := make(map[string]string)
386 for _, identity := range resolvedIds {
387 if !identity.Handle.IsInvalidHandle() {
388 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
389 } else {
390 didHandleMap[identity.DID.String()] = identity.DID.String()
391 }
392 }
393
394 s.pages.RepoPulls(w, pages.RepoPullsParams{
395 LoggedInUser: s.auth.GetUser(r),
396 RepoInfo: f.RepoInfo(s, user),
397 Pulls: pulls,
398 DidHandleMap: didHandleMap,
399 FilteringBy: state,
400 })
401 return
402}
403
404func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
405 user := s.auth.GetUser(r)
406 f, err := fullyResolvedRepo(r)
407 if err != nil {
408 log.Println("failed to get repo and knot", err)
409 return
410 }
411
412 pull, ok := r.Context().Value("pull").(*db.Pull)
413 if !ok {
414 log.Println("failed to get pull")
415 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
416 return
417 }
418
419 roundNumberStr := chi.URLParam(r, "round")
420 roundNumber, err := strconv.Atoi(roundNumberStr)
421 if err != nil || roundNumber >= len(pull.Submissions) {
422 http.Error(w, "bad round id", http.StatusBadRequest)
423 log.Println("failed to parse round id", err)
424 return
425 }
426
427 switch r.Method {
428 case http.MethodGet:
429 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
430 LoggedInUser: user,
431 RepoInfo: f.RepoInfo(s, user),
432 Pull: pull,
433 RoundNumber: roundNumber,
434 })
435 return
436 case http.MethodPost:
437 body := r.FormValue("body")
438 if body == "" {
439 s.pages.Notice(w, "pull", "Comment body is required")
440 return
441 }
442
443 // Start a transaction
444 tx, err := s.db.BeginTx(r.Context(), nil)
445 if err != nil {
446 log.Println("failed to start transaction", err)
447 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
448 return
449 }
450 defer tx.Rollback()
451
452 createdAt := time.Now().Format(time.RFC3339)
453 ownerDid := user.Did
454
455 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
456 if err != nil {
457 log.Println("failed to get pull at", err)
458 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
459 return
460 }
461
462 atUri := f.RepoAt.String()
463 client, _ := s.auth.AuthorizedClient(r)
464 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
465 Collection: tangled.RepoPullCommentNSID,
466 Repo: user.Did,
467 Rkey: s.TID(),
468 Record: &lexutil.LexiconTypeDecoder{
469 Val: &tangled.RepoPullComment{
470 Repo: &atUri,
471 Pull: pullAt,
472 Owner: &ownerDid,
473 Body: &body,
474 CreatedAt: &createdAt,
475 },
476 },
477 })
478 if err != nil {
479 log.Println("failed to create pull comment", err)
480 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
481 return
482 }
483
484 // Create the pull comment in the database with the commentAt field
485 commentId, err := db.NewPullComment(tx, &db.PullComment{
486 OwnerDid: user.Did,
487 RepoAt: f.RepoAt.String(),
488 PullId: pull.PullId,
489 Body: body,
490 CommentAt: atResp.Uri,
491 SubmissionId: pull.Submissions[roundNumber].ID,
492 })
493 if err != nil {
494 log.Println("failed to create pull comment", err)
495 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
496 return
497 }
498
499 // Commit the transaction
500 if err = tx.Commit(); err != nil {
501 log.Println("failed to commit transaction", err)
502 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
503 return
504 }
505
506 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
507 return
508 }
509}
510
511func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
512 user := s.auth.GetUser(r)
513 f, err := fullyResolvedRepo(r)
514 if err != nil {
515 log.Println("failed to get repo and knot", err)
516 return
517 }
518
519 switch r.Method {
520 case http.MethodGet:
521 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
522 if err != nil {
523 log.Printf("failed to create unsigned client for %s", f.Knot)
524 s.pages.Error503(w)
525 return
526 }
527
528 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
529 if err != nil {
530 log.Println("failed to reach knotserver", err)
531 return
532 }
533
534 body, err := io.ReadAll(resp.Body)
535 if err != nil {
536 log.Printf("Error reading response body: %v", err)
537 return
538 }
539
540 var result types.RepoBranchesResponse
541 err = json.Unmarshal(body, &result)
542 if err != nil {
543 log.Println("failed to parse response:", err)
544 return
545 }
546
547 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
548 LoggedInUser: user,
549 RepoInfo: f.RepoInfo(s, user),
550 Branches: result.Branches,
551 })
552 case http.MethodPost:
553 title := r.FormValue("title")
554 body := r.FormValue("body")
555 targetBranch := r.FormValue("targetBranch")
556 fromFork := r.FormValue("fork")
557 sourceBranch := r.FormValue("sourceBranch")
558 patch := r.FormValue("patch")
559
560 // Validate required fields for all PR types
561 if title == "" || body == "" || targetBranch == "" {
562 s.pages.Notice(w, "pull", "Title, body and target branch are required.")
563 return
564 }
565
566 // Determine PR type based on input parameters
567 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
568 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
569 isForkBased := fromFork != "" && sourceBranch != ""
570 isPatchBased := patch != "" && !isBranchBased && !isForkBased
571
572 // Validate we have at least one valid PR creation method
573 if !isBranchBased && !isPatchBased && !isForkBased {
574 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
575 return
576 }
577
578 // Can't mix branch-based and patch-based approaches
579 if isBranchBased && patch != "" {
580 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
581 return
582 }
583
584 // Handle the PR creation based on the type
585 if isBranchBased {
586 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
587 } else if isForkBased {
588 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
589 } else if isPatchBased {
590 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
591 }
592 return
593 }
594}
595
596func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
597 pullSource := &db.PullSource{
598 Branch: sourceBranch,
599 }
600 recordPullSource := &tangled.RepoPull_Source{
601 Branch: sourceBranch,
602 }
603
604 // Generate a patch using /compare
605 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
606 if err != nil {
607 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
608 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
609 return
610 }
611
612 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
613 switch resp.StatusCode {
614 case 404:
615 case 400:
616 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
617 return
618 }
619
620 respBody, err := io.ReadAll(resp.Body)
621 if err != nil {
622 log.Println("failed to compare across branches")
623 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
624 return
625 }
626 defer resp.Body.Close()
627
628 var diffTreeResponse types.RepoDiffTreeResponse
629 err = json.Unmarshal(respBody, &diffTreeResponse)
630 if err != nil {
631 log.Println("failed to unmarshal diff tree response", err)
632 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
633 return
634 }
635
636 sourceRev := diffTreeResponse.DiffTree.Rev2
637 patch := diffTreeResponse.DiffTree.Patch
638
639 if !isPatchValid(patch) {
640 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
641 return
642 }
643
644 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
645}
646
647func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
648 if !isPatchValid(patch) {
649 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
650 return
651 }
652
653 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
654}
655
656func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
657 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
658 if errors.Is(err, sql.ErrNoRows) {
659 s.pages.Notice(w, "pull", "No such fork.")
660 return
661 } else if err != nil {
662 log.Println("failed to fetch fork:", err)
663 s.pages.Notice(w, "pull", "Failed to fetch fork.")
664 return
665 }
666
667 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
668 if err != nil {
669 log.Println("failed to fetch registration key:", err)
670 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
671 return
672 }
673
674 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
675 if err != nil {
676 log.Println("failed to create signed client:", err)
677 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
678 return
679 }
680
681 us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
682 if err != nil {
683 log.Println("failed to create unsigned client:", err)
684 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
685 return
686 }
687
688 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
689 if err != nil {
690 log.Println("failed to create hidden ref:", err, resp.StatusCode)
691 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
692 return
693 }
694
695 switch resp.StatusCode {
696 case 404:
697 case 400:
698 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
699 return
700 }
701
702 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
703 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
704 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
705 // hiddenRef: hidden/feature-1/main (on repo-fork)
706 // targetBranch: main (on repo-1)
707 // sourceBranch: feature-1 (on repo-fork)
708 diffResp, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
709 if err != nil {
710 log.Println("failed to compare across branches", err)
711 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
712 return
713 }
714
715 respBody, err := io.ReadAll(diffResp.Body)
716 if err != nil {
717 log.Println("failed to read response body", err)
718 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
719 return
720 }
721
722 defer resp.Body.Close()
723
724 var diffTreeResponse types.RepoDiffTreeResponse
725 err = json.Unmarshal(respBody, &diffTreeResponse)
726 if err != nil {
727 log.Println("failed to unmarshal diff tree response", err)
728 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
729 return
730 }
731
732 sourceRev := diffTreeResponse.DiffTree.Rev2
733 patch := diffTreeResponse.DiffTree.Patch
734
735 if !isPatchValid(patch) {
736 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
737 return
738 }
739
740 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
741 if err != nil {
742 log.Println("failed to parse fork AT URI", err)
743 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
744 return
745 }
746
747 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
748 Branch: sourceBranch,
749 RepoAt: &forkAtUri,
750 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
751}
752
753func (s *State) createPullRequest(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch, sourceRev string, pullSource *db.PullSource, recordPullSource *tangled.RepoPull_Source) {
754 tx, err := s.db.BeginTx(r.Context(), nil)
755 if err != nil {
756 log.Println("failed to start tx")
757 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
758 return
759 }
760 defer tx.Rollback()
761
762 rkey := s.TID()
763 initialSubmission := db.PullSubmission{
764 Patch: patch,
765 SourceRev: sourceRev,
766 }
767 err = db.NewPull(tx, &db.Pull{
768 Title: title,
769 Body: body,
770 TargetBranch: targetBranch,
771 OwnerDid: user.Did,
772 RepoAt: f.RepoAt,
773 Rkey: rkey,
774 Submissions: []*db.PullSubmission{
775 &initialSubmission,
776 },
777 PullSource: pullSource,
778 })
779 if err != nil {
780 log.Println("failed to create pull request", err)
781 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
782 return
783 }
784 client, _ := s.auth.AuthorizedClient(r)
785 pullId, err := db.NextPullId(s.db, f.RepoAt)
786 if err != nil {
787 log.Println("failed to get pull id", err)
788 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
789 return
790 }
791
792 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
793 Collection: tangled.RepoPullNSID,
794 Repo: user.Did,
795 Rkey: rkey,
796 Record: &lexutil.LexiconTypeDecoder{
797 Val: &tangled.RepoPull{
798 Title: title,
799 PullId: int64(pullId),
800 TargetRepo: string(f.RepoAt),
801 TargetBranch: targetBranch,
802 Patch: patch,
803 Source: recordPullSource,
804 },
805 },
806 })
807
808 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
809 if err != nil {
810 log.Println("failed to get pull id", err)
811 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
812 return
813 }
814
815 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
816}
817
818func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
819 user := s.auth.GetUser(r)
820 f, err := fullyResolvedRepo(r)
821 if err != nil {
822 log.Println("failed to get repo and knot", err)
823 return
824 }
825
826 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
827 RepoInfo: f.RepoInfo(s, user),
828 })
829}
830
831func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
832 user := s.auth.GetUser(r)
833 f, err := fullyResolvedRepo(r)
834 if err != nil {
835 log.Println("failed to get repo and knot", err)
836 return
837 }
838
839 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
840 if err != nil {
841 log.Printf("failed to create unsigned client for %s", f.Knot)
842 s.pages.Error503(w)
843 return
844 }
845
846 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
847 if err != nil {
848 log.Println("failed to reach knotserver", err)
849 return
850 }
851
852 body, err := io.ReadAll(resp.Body)
853 if err != nil {
854 log.Printf("Error reading response body: %v", err)
855 return
856 }
857
858 var result types.RepoBranchesResponse
859 err = json.Unmarshal(body, &result)
860 if err != nil {
861 log.Println("failed to parse response:", err)
862 return
863 }
864
865 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
866 RepoInfo: f.RepoInfo(s, user),
867 Branches: result.Branches,
868 })
869}
870
871func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
872 user := s.auth.GetUser(r)
873 f, err := fullyResolvedRepo(r)
874 if err != nil {
875 log.Println("failed to get repo and knot", err)
876 return
877 }
878
879 forks, err := db.GetForksByDid(s.db, user.Did)
880 if err != nil {
881 log.Println("failed to get forks", err)
882 return
883 }
884
885 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
886 RepoInfo: f.RepoInfo(s, user),
887 Forks: forks,
888 })
889}
890
891func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
892 user := s.auth.GetUser(r)
893
894 f, err := fullyResolvedRepo(r)
895 if err != nil {
896 log.Println("failed to get repo and knot", err)
897 return
898 }
899
900 forkVal := r.URL.Query().Get("fork")
901
902 // fork repo
903 repo, err := db.GetRepo(s.db, user.Did, forkVal)
904 if err != nil {
905 log.Println("failed to get repo", user.Did, forkVal)
906 return
907 }
908
909 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
910 if err != nil {
911 log.Printf("failed to create unsigned client for %s", repo.Knot)
912 s.pages.Error503(w)
913 return
914 }
915
916 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
917 if err != nil {
918 log.Println("failed to reach knotserver for source branches", err)
919 return
920 }
921
922 sourceBody, err := io.ReadAll(sourceResp.Body)
923 if err != nil {
924 log.Println("failed to read source response body", err)
925 return
926 }
927 defer sourceResp.Body.Close()
928
929 var sourceResult types.RepoBranchesResponse
930 err = json.Unmarshal(sourceBody, &sourceResult)
931 if err != nil {
932 log.Println("failed to parse source branches response:", err)
933 return
934 }
935
936 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
937 if err != nil {
938 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
939 s.pages.Error503(w)
940 return
941 }
942
943 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
944 if err != nil {
945 log.Println("failed to reach knotserver for target branches", err)
946 return
947 }
948
949 targetBody, err := io.ReadAll(targetResp.Body)
950 if err != nil {
951 log.Println("failed to read target response body", err)
952 return
953 }
954 defer targetResp.Body.Close()
955
956 var targetResult types.RepoBranchesResponse
957 err = json.Unmarshal(targetBody, &targetResult)
958 if err != nil {
959 log.Println("failed to parse target branches response:", err)
960 return
961 }
962
963 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
964 RepoInfo: f.RepoInfo(s, user),
965 SourceBranches: sourceResult.Branches,
966 TargetBranches: targetResult.Branches,
967 })
968}
969
970func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
971 user := s.auth.GetUser(r)
972 f, err := fullyResolvedRepo(r)
973 if err != nil {
974 log.Println("failed to get repo and knot", err)
975 return
976 }
977
978 pull, ok := r.Context().Value("pull").(*db.Pull)
979 if !ok {
980 log.Println("failed to get pull")
981 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
982 return
983 }
984
985 switch r.Method {
986 case http.MethodGet:
987 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
988 RepoInfo: f.RepoInfo(s, user),
989 Pull: pull,
990 })
991 return
992 case http.MethodPost:
993 patch := r.FormValue("patch")
994 var sourceRev string
995 var recordPullSource *tangled.RepoPull_Source
996
997 var ownerDid, repoName, knotName string
998 var isSameRepo bool = pull.IsSameRepoBranch()
999 sourceBranch := pull.PullSource.Branch
1000 targetBranch := pull.TargetBranch
1001 recordPullSource = &tangled.RepoPull_Source{
1002 Branch: sourceBranch,
1003 }
1004
1005 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
1006 if isSameRepo && isPushAllowed {
1007 ownerDid = f.OwnerDid()
1008 repoName = f.RepoName
1009 knotName = f.Knot
1010 } else if !isSameRepo {
1011 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1012 if err != nil {
1013 log.Println("failed to get source repo", err)
1014 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1015 return
1016 }
1017 ownerDid = sourceRepo.Did
1018 repoName = sourceRepo.Name
1019 knotName = sourceRepo.Knot
1020 }
1021
1022 if sourceBranch != "" && knotName != "" {
1023 // extract patch by performing compare
1024 ksClient, err := NewUnsignedClient(knotName, s.config.Dev)
1025 if err != nil {
1026 log.Printf("failed to create client for %s: %s", knotName, err)
1027 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1028 return
1029 }
1030
1031 if !isSameRepo {
1032 secret, err := db.GetRegistrationKey(s.db, knotName)
1033 if err != nil {
1034 log.Printf("failed to get registration key for %s: %s", knotName, err)
1035 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1036 return
1037 }
1038 // update the hidden tracking branch to latest
1039 signedClient, err := NewSignedClient(knotName, secret, s.config.Dev)
1040 if err != nil {
1041 log.Printf("failed to create signed client for %s: %s", knotName, err)
1042 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1043 return
1044 }
1045 resp, err := signedClient.NewHiddenRef(ownerDid, repoName, sourceBranch, targetBranch)
1046 if err != nil || resp.StatusCode != http.StatusNoContent {
1047 log.Printf("failed to update tracking branch: %s", err)
1048 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1049 return
1050 }
1051 }
1052
1053 var compareResp *http.Response
1054 if !isSameRepo {
1055 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
1056 compareResp, err = ksClient.Compare(ownerDid, repoName, hiddenRef, sourceBranch)
1057 } else {
1058 compareResp, err = ksClient.Compare(ownerDid, repoName, targetBranch, sourceBranch)
1059 }
1060 if err != nil {
1061 log.Printf("failed to compare branches: %s", err)
1062 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1063 return
1064 }
1065 defer compareResp.Body.Close()
1066
1067 switch compareResp.StatusCode {
1068 case 404:
1069 case 400:
1070 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
1071 return
1072 }
1073
1074 respBody, err := io.ReadAll(compareResp.Body)
1075 if err != nil {
1076 log.Println("failed to compare across branches")
1077 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1078 return
1079 }
1080 defer compareResp.Body.Close()
1081
1082 var diffTreeResponse types.RepoDiffTreeResponse
1083 err = json.Unmarshal(respBody, &diffTreeResponse)
1084 if err != nil {
1085 log.Println("failed to unmarshal diff tree response", err)
1086 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1087 return
1088 }
1089
1090 sourceRev = diffTreeResponse.DiffTree.Rev2
1091 patch = diffTreeResponse.DiffTree.Patch
1092 }
1093
1094 if patch == "" {
1095 s.pages.Notice(w, "resubmit-error", "Patch is empty.")
1096 return
1097 }
1098
1099 if patch == pull.LatestPatch() {
1100 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1101 return
1102 }
1103
1104 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1105 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1106 return
1107 }
1108
1109 if !isPatchValid(patch) {
1110 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
1111 return
1112 }
1113
1114 tx, err := s.db.BeginTx(r.Context(), nil)
1115 if err != nil {
1116 log.Println("failed to start tx")
1117 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1118 return
1119 }
1120 defer tx.Rollback()
1121
1122 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1123 if err != nil {
1124 log.Println("failed to create pull request", err)
1125 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1126 return
1127 }
1128 client, _ := s.auth.AuthorizedClient(r)
1129
1130 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1131 if err != nil {
1132 // failed to get record
1133 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1134 return
1135 }
1136
1137 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1138 Collection: tangled.RepoPullNSID,
1139 Repo: user.Did,
1140 Rkey: pull.Rkey,
1141 SwapRecord: ex.Cid,
1142 Record: &lexutil.LexiconTypeDecoder{
1143 Val: &tangled.RepoPull{
1144 Title: pull.Title,
1145 PullId: int64(pull.PullId),
1146 TargetRepo: string(f.RepoAt),
1147 TargetBranch: pull.TargetBranch,
1148 Patch: patch, // new patch
1149 Source: recordPullSource,
1150 },
1151 },
1152 })
1153 if err != nil {
1154 log.Println("failed to update record", err)
1155 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1156 return
1157 }
1158
1159 if err = tx.Commit(); err != nil {
1160 log.Println("failed to commit transaction", err)
1161 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1162 return
1163 }
1164
1165 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1166 return
1167 }
1168}
1169
1170func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1171 f, err := fullyResolvedRepo(r)
1172 if err != nil {
1173 log.Println("failed to resolve repo:", err)
1174 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1175 return
1176 }
1177
1178 pull, ok := r.Context().Value("pull").(*db.Pull)
1179 if !ok {
1180 log.Println("failed to get pull")
1181 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1182 return
1183 }
1184
1185 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1186 if err != nil {
1187 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1188 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1189 return
1190 }
1191
1192 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1193 if err != nil {
1194 log.Printf("resolving identity: %s", err)
1195 w.WriteHeader(http.StatusNotFound)
1196 return
1197 }
1198
1199 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1200 if err != nil {
1201 log.Printf("failed to get primary email: %s", err)
1202 }
1203
1204 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1205 if err != nil {
1206 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1207 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1208 return
1209 }
1210
1211 // Merge the pull request
1212 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1213 if err != nil {
1214 log.Printf("failed to merge pull request: %s", err)
1215 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1216 return
1217 }
1218
1219 if resp.StatusCode == http.StatusOK {
1220 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1221 if err != nil {
1222 log.Printf("failed to update pull request status in database: %s", err)
1223 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1224 return
1225 }
1226 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1227 } else {
1228 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1229 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1230 }
1231}
1232
1233func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1234 user := s.auth.GetUser(r)
1235
1236 f, err := fullyResolvedRepo(r)
1237 if err != nil {
1238 log.Println("malformed middleware")
1239 return
1240 }
1241
1242 pull, ok := r.Context().Value("pull").(*db.Pull)
1243 if !ok {
1244 log.Println("failed to get pull")
1245 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1246 return
1247 }
1248
1249 // auth filter: only owner or collaborators can close
1250 roles := RolesInRepo(s, user, f)
1251 isCollaborator := roles.IsCollaborator()
1252 isPullAuthor := user.Did == pull.OwnerDid
1253 isCloseAllowed := isCollaborator || isPullAuthor
1254 if !isCloseAllowed {
1255 log.Println("failed to close pull")
1256 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1257 return
1258 }
1259
1260 // Start a transaction
1261 tx, err := s.db.BeginTx(r.Context(), nil)
1262 if err != nil {
1263 log.Println("failed to start transaction", err)
1264 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1265 return
1266 }
1267
1268 // Close the pull in the database
1269 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1270 if err != nil {
1271 log.Println("failed to close pull", err)
1272 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1273 return
1274 }
1275
1276 // Commit the transaction
1277 if err = tx.Commit(); err != nil {
1278 log.Println("failed to commit transaction", err)
1279 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1280 return
1281 }
1282
1283 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1284 return
1285}
1286
1287func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1288 user := s.auth.GetUser(r)
1289
1290 f, err := fullyResolvedRepo(r)
1291 if err != nil {
1292 log.Println("failed to resolve repo", err)
1293 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1294 return
1295 }
1296
1297 pull, ok := r.Context().Value("pull").(*db.Pull)
1298 if !ok {
1299 log.Println("failed to get pull")
1300 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1301 return
1302 }
1303
1304 // auth filter: only owner or collaborators can close
1305 roles := RolesInRepo(s, user, f)
1306 isCollaborator := roles.IsCollaborator()
1307 isPullAuthor := user.Did == pull.OwnerDid
1308 isCloseAllowed := isCollaborator || isPullAuthor
1309 if !isCloseAllowed {
1310 log.Println("failed to close pull")
1311 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1312 return
1313 }
1314
1315 // Start a transaction
1316 tx, err := s.db.BeginTx(r.Context(), nil)
1317 if err != nil {
1318 log.Println("failed to start transaction", err)
1319 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1320 return
1321 }
1322
1323 // Reopen the pull in the database
1324 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1325 if err != nil {
1326 log.Println("failed to reopen pull", err)
1327 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1328 return
1329 }
1330
1331 // Commit the transaction
1332 if err = tx.Commit(); err != nil {
1333 log.Println("failed to commit transaction", err)
1334 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1335 return
1336 }
1337
1338 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1339 return
1340}
1341
1342// Very basic validation to check if it looks like a diff/patch
1343// A valid patch usually starts with diff or --- lines
1344func isPatchValid(patch string) bool {
1345 // Basic validation to check if it looks like a diff/patch
1346 // A valid patch usually starts with diff or --- lines
1347 if len(patch) == 0 {
1348 return false
1349 }
1350
1351 lines := strings.Split(patch, "\n")
1352 if len(lines) < 2 {
1353 return false
1354 }
1355
1356 // Check for common patch format markers
1357 firstLine := strings.TrimSpace(lines[0])
1358 return strings.HasPrefix(firstLine, "diff ") ||
1359 strings.HasPrefix(firstLine, "--- ") ||
1360 strings.HasPrefix(firstLine, "Index: ") ||
1361 strings.HasPrefix(firstLine, "+++ ") ||
1362 strings.HasPrefix(firstLine, "@@ ")
1363}