Lewis: May this revision serve well! lewis@tangled.org
+852
-3082
Diff
round #3
+39
api/tangled/repodescribeRepo.go
+39
api/tangled/repodescribeRepo.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.describeRepo
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoDescribeRepoNSID = "sh.tangled.repo.describeRepo"
15
+
)
16
+
17
+
// RepoDescribeRepo_Output is the output of a sh.tangled.repo.describeRepo call.
18
+
type RepoDescribeRepo_Output struct {
19
+
// ownerDid: DID of the current owner according to the knot.
20
+
OwnerDid string `json:"ownerDid" cborgen:"ownerDid"`
21
+
RepoDid string `json:"repoDid" cborgen:"repoDid"`
22
+
// rkey: Current rkey of the sh.tangled.repo record tracked by this knot
23
+
Rkey string `json:"rkey" cborgen:"rkey"`
24
+
}
25
+
26
+
// RepoDescribeRepo calls the XRPC method "sh.tangled.repo.describeRepo".
27
+
//
28
+
// repoDid: DID of the git repo as minted by the knot
29
+
func RepoDescribeRepo(ctx context.Context, c util.LexClient, repoDid string) (*RepoDescribeRepo_Output, error) {
30
+
var out RepoDescribeRepo_Output
31
+
32
+
params := map[string]interface{}{}
33
+
params["repoDid"] = repoDid
34
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.describeRepo", params, nil, &out); err != nil {
35
+
return nil, err
36
+
}
37
+
38
+
return &out, nil
39
+
}
+4
-2
appview/ingester.go
+4
-2
appview/ingester.go
···
29
29
"tangled.org/core/appview/db"
30
30
"tangled.org/core/appview/models"
31
31
"tangled.org/core/appview/notify"
32
+
"tangled.org/core/appview/repoverify"
32
33
"tangled.org/core/appview/serververify"
33
34
"tangled.org/core/appview/validator"
34
35
"tangled.org/core/idresolver"
···
45
46
Logger *slog.Logger
46
47
Validator *validator.Validator
47
48
Notifier notify.Notifier
49
+
Verifier repoverify.Verifier
48
50
}
49
51
50
52
type processFunc func(ctx context.Context, e *jmodels.Event) error
···
1025
1027
return fmt.Errorf("failed to validate issue: %w", err)
1026
1028
}
1027
1029
1028
-
if record.Repo != nil {
1029
-
repo, repoErr := db.GetRepoByAtUri(i.Db, *record.Repo)
1030
+
if record.Repo != "" && !strings.HasPrefix(record.Repo, "did:") {
1031
+
repo, repoErr := db.GetRepoByAtUri(i.Db, record.Repo)
1030
1032
if repoErr == nil && repo.RepoDid != "" {
1031
1033
if enqErr := db.EnqueuePdsRecordMigration(ctx, i.Db, "add-repo-did", syntax.DID(did), syntax.NSID(tangled.RepoIssueNSID), syntax.RecordKey(e.Commit.RKey)); enqErr != nil {
1032
1034
l.Warn("failed to enqueue PDS rewrite for issue", "err", enqErr, "did", did, "repoDid", repo.RepoDid)
+61
-1
appview/ingester_repo.go
+61
-1
appview/ingester_repo.go
···
6
6
"encoding/json"
7
7
"errors"
8
8
"fmt"
9
+
"log/slog"
9
10
"slices"
11
+
"strings"
10
12
11
13
"github.com/bluesky-social/indigo/atproto/syntax"
12
14
jmodels "github.com/bluesky-social/jetstream/pkg/models"
13
15
"tangled.org/core/api/tangled"
14
16
"tangled.org/core/appview/db"
15
17
"tangled.org/core/appview/models"
18
+
"tangled.org/core/appview/repoverify"
16
19
"tangled.org/core/orm"
17
20
)
18
21
···
47
50
}
48
51
repoDid := *record.RepoDid
49
52
50
-
_, err := db.GetRepo(i.Db,
53
+
proceed, err := i.verifyOwnership(ctx, l, repoDid, e.Did, record.Knot)
54
+
if err != nil {
55
+
return err
56
+
}
57
+
if !proceed {
58
+
return nil
59
+
}
60
+
61
+
_, err = db.GetRepo(i.Db,
51
62
orm.FilterEq("did", e.Did),
52
63
orm.FilterEq("rkey", e.Commit.RKey),
53
64
)
···
165
176
return nil
166
177
}
167
178
179
+
proceed, err := i.verifyOwnership(ctx, l, *record.RepoDid, e.Did, record.Knot)
180
+
if err != nil {
181
+
return err
182
+
}
183
+
if !proceed {
184
+
return nil
185
+
}
186
+
168
187
current, err := db.GetRepo(i.Db,
169
188
orm.FilterEq("did", e.Did),
170
189
orm.FilterEq("rkey", e.Commit.RKey),
···
177
196
return fmt.Errorf("failed to fetch repo for ingest: %w", err)
178
197
}
179
198
199
+
if current.RepoDid != "" && current.RepoDid != *record.RepoDid {
200
+
l.Warn("rejecting repo update: repoDid is immutable",
201
+
"currentRepoDid", current.RepoDid,
202
+
"recordRepoDid", *record.RepoDid,
203
+
)
204
+
return nil
205
+
}
206
+
180
207
desired := repoFromRecord(current, &record)
181
208
182
209
if current.Source != desired.Source {
···
330
357
}
331
358
return *s
332
359
}
360
+
361
+
func (i *Ingester) verifyOwnership(ctx context.Context, l *slog.Logger, repoDid, eventDid, recordKnot string) (bool, error) {
362
+
if i.Verifier == nil {
363
+
return false, fmt.Errorf("ingester has no repo ownership verifier configured")
364
+
}
365
+
rd, err := repoverify.NewRepoDid(repoDid)
366
+
if err != nil {
367
+
l.Warn("rejecting repo event: invalid repoDid on record", "repoDid", repoDid, "err", err)
368
+
return false, nil
369
+
}
370
+
result, err := i.Verifier(ctx, rd)
371
+
if err != nil {
372
+
return false, fmt.Errorf("verify repo ownership: %w", err)
373
+
}
374
+
if result.OwnerDid.String() != eventDid {
375
+
l.Warn("rejecting repo event: owner mismatch",
376
+
"repoDid", repoDid,
377
+
"claimedOwner", eventDid,
378
+
"knotOwner", result.OwnerDid.String(),
379
+
"knot", result.KnotURL.String(),
380
+
)
381
+
return false, nil
382
+
}
383
+
if !strings.EqualFold(recordKnot, result.KnotURL.Host) {
384
+
l.Warn("rejecting repo event: record knot does not match DID-doc endpoint",
385
+
"repoDid", repoDid,
386
+
"recordKnot", recordKnot,
387
+
"canonicalKnot", result.KnotURL.Host,
388
+
)
389
+
return false, nil
390
+
}
391
+
return true, nil
392
+
}
+311
-14
appview/ingester_repo_test.go
+311
-14
appview/ingester_repo_test.go
···
7
7
"errors"
8
8
"io"
9
9
"log/slog"
10
+
"net/url"
10
11
"path/filepath"
11
12
"testing"
12
13
···
16
17
"tangled.org/core/appview/db"
17
18
"tangled.org/core/appview/models"
18
19
"tangled.org/core/appview/notify"
20
+
"tangled.org/core/appview/repoverify"
19
21
"tangled.org/core/orm"
20
22
)
21
23
24
+
func mustKnotURL(t *testing.T, raw string) *url.URL {
25
+
t.Helper()
26
+
u, err := repoverify.ParseKnotEndpoint(raw, true)
27
+
if err != nil {
28
+
t.Fatalf("ParseKnotEndpoint(%q): %v", raw, err)
29
+
}
30
+
return u
31
+
}
32
+
33
+
func acceptOwner(t *testing.T, e *jmodels.Event) repoverify.Verifier {
34
+
t.Helper()
35
+
knot := mustKnotURL(t, "https://knot.example")
36
+
return func(_ context.Context, repoDid repoverify.RepoDid) (repoverify.Result, error) {
37
+
return repoverify.Result{
38
+
RepoDid: repoDid,
39
+
OwnerDid: repoverify.OwnerDid(e.Did),
40
+
KnotURL: knot,
41
+
}, nil
42
+
}
43
+
}
44
+
45
+
func stubVerifier(result repoverify.Result, err error) repoverify.Verifier {
46
+
return func(_ context.Context, _ repoverify.RepoDid) (repoverify.Result, error) {
47
+
return result, err
48
+
}
49
+
}
50
+
22
51
type spyNotifier struct {
23
52
notify.BaseNotifier
24
53
creates int
···
50
79
return ing, spy
51
80
}
52
81
82
+
func withVerifier(ing *Ingester, v repoverify.Verifier) *Ingester {
83
+
ing.Verifier = v
84
+
return ing
85
+
}
86
+
87
+
func ingestAcceptingOwner(t *testing.T, ing *Ingester, e *jmodels.Event) error {
88
+
t.Helper()
89
+
ing.Verifier = acceptOwner(t, e)
90
+
return ing.ingestRepo(context.Background(), e)
91
+
}
92
+
53
93
func seedRepoRow(t *testing.T, ing *Ingester, did, knot, name, rkey, repoDid string) *models.Repo {
54
94
t.Helper()
55
95
tx, err := ing.Db.Begin()
···
126
166
RepoDid: ptr("did:plc:repo1"),
127
167
})
128
168
129
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
169
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
130
170
t.Fatalf("ingestRepo: %v", err)
131
171
}
132
172
···
155
195
RepoDid: ptr("did:plc:repo1"),
156
196
})
157
197
158
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
198
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
159
199
t.Fatalf("ingestRepo: %v", err)
160
200
}
161
201
if spy.creates != 0 {
···
173
213
RepoDid: ptr("did:plc:repo1"),
174
214
})
175
215
176
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
216
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
177
217
t.Fatalf("ingestRepo: %v", err)
178
218
}
179
219
···
217
257
Name: ptr("myrepo"),
218
258
})
219
259
220
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
260
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
221
261
t.Fatalf("ingestRepo: %v", err)
222
262
}
223
263
if spy.creates != 0 {
···
238
278
RepoDid: ptr("did:plc:repo1"),
239
279
})
240
280
241
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
281
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
242
282
t.Fatalf("ingestRepo: %v", err)
243
283
}
244
284
···
264
304
RepoDid: ptr("did:plc:repo1"),
265
305
})
266
306
267
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
307
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
268
308
t.Fatalf("ingestRepo: %v", err)
269
309
}
270
310
···
287
327
RepoDid: ptr("did:plc:repo1"),
288
328
})
289
329
290
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
330
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
291
331
t.Fatalf("ingestRepo: %v", err)
292
332
}
293
333
···
315
355
e = makeDeleteEvent("did:plc:nobody", "ghost")
316
356
}
317
357
318
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
358
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
319
359
t.Fatalf("ingestRepo: %v", err)
320
360
}
321
361
})
···
331
371
Name: ptr("bar"),
332
372
})
333
373
334
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
374
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
335
375
t.Fatalf("ingestRepo: %v", err)
336
376
}
337
377
···
346
386
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1")
347
387
348
388
e := makeDeleteEvent("did:plc:akshay", "foo")
349
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
389
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
350
390
t.Fatalf("ingestRepo: %v", err)
351
391
}
352
392
···
373
413
},
374
414
}
375
415
376
-
if err := ing.ingestRepo(context.Background(), e); err == nil {
416
+
if err := ingestAcceptingOwner(t, ing, e); err == nil {
377
417
t.Errorf("ingestRepo with malformed record: err = nil, want error")
378
418
}
379
419
}
···
394
434
Name: ptr("NewName"),
395
435
RepoDid: ptr("did:plc:repo1"),
396
436
})
397
-
if err := ing.ingestRepo(context.Background(), createEvt); err != nil {
437
+
if err := ingestAcceptingOwner(t, ing, createEvt); err != nil {
398
438
t.Fatalf("ingest create: %v", err)
399
439
}
400
440
401
441
deleteEvt := makeDeleteEvent("did:plc:akshay", "oldname")
402
-
if err := ing.ingestRepo(context.Background(), deleteEvt); err != nil {
442
+
if err := ingestAcceptingOwner(t, ing, deleteEvt); err != nil {
403
443
t.Fatalf("ingest delete: %v", err)
404
444
}
405
445
···
446
486
RepoDid: ptr("did:plc:repo1"),
447
487
})
448
488
449
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
489
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
450
490
t.Fatalf("ingestRepo: %v", err)
451
491
}
452
492
···
455
495
t.Errorf("name should fall back to rkey: got %q, want %q", r.Name, "myrepo")
456
496
}
457
497
}
498
+
499
+
func TestIngestRepo_CreateSquatRejected(t *testing.T) {
500
+
ing, spy := newTestIngester(t)
501
+
502
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "squatrepo", tangled.Repo{
503
+
Knot: "knot.example",
504
+
RepoDid: ptr("did:plc:akshays-repo"),
505
+
})
506
+
507
+
withVerifier(ing, stubVerifier(repoverify.Result{
508
+
RepoDid: "did:plc:akshays-repo",
509
+
OwnerDid: "did:plc:akshay",
510
+
KnotURL: mustKnotURL(t, "https://knot.example"),
511
+
}, nil))
512
+
513
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
514
+
t.Fatalf("ingestRepo: %v", err)
515
+
}
516
+
517
+
if _, err := db.GetRepo(ing.Db,
518
+
orm.FilterEq("did", "did:plc:boltless"),
519
+
orm.FilterEq("rkey", "squatrepo"),
520
+
); !errors.Is(err, sql.ErrNoRows) {
521
+
t.Fatalf("boltless's squat row should not exist, got err=%v", err)
522
+
}
523
+
if spy.creates != 0 {
524
+
t.Errorf("NewRepo called %d times despite rejection", spy.creates)
525
+
}
526
+
}
527
+
528
+
func TestIngestRepo_CreateHijackExistingRepoRejected(t *testing.T) {
529
+
ing, spy := newTestIngester(t)
530
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo")
531
+
532
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "takeover", tangled.Repo{
533
+
Knot: "knot.example",
534
+
RepoDid: ptr("did:plc:akshays-repo"),
535
+
})
536
+
537
+
withVerifier(ing, stubVerifier(repoverify.Result{
538
+
RepoDid: "did:plc:akshays-repo",
539
+
OwnerDid: "did:plc:akshay",
540
+
KnotURL: mustKnotURL(t, "https://knot.example"),
541
+
}, nil))
542
+
543
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
544
+
t.Fatalf("ingestRepo: %v", err)
545
+
}
546
+
547
+
akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey")
548
+
if akshay.Did != "did:plc:akshay" || akshay.Rkey != "akshayskey" {
549
+
t.Errorf("akshay's row mutated: %+v", akshay)
550
+
}
551
+
if spy.renames != 0 {
552
+
t.Errorf("RenameRepo called %d times despite rejection", spy.renames)
553
+
}
554
+
}
555
+
556
+
func TestIngestRepo_CreateRenameIgnoresRkeyDrift(t *testing.T) {
557
+
ing, spy := newTestIngester(t)
558
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldrkey", "did:plc:akshays-repo")
559
+
560
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newrkey", tangled.Repo{
561
+
Knot: "knot.example",
562
+
Name: ptr("newname"),
563
+
RepoDid: ptr("did:plc:akshays-repo"),
564
+
})
565
+
566
+
withVerifier(ing, stubVerifier(repoverify.Result{
567
+
RepoDid: "did:plc:akshays-repo",
568
+
OwnerDid: "did:plc:akshay",
569
+
KnotURL: mustKnotURL(t, "https://knot.example"),
570
+
}, nil))
571
+
572
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
573
+
t.Fatalf("ingestRepo: %v", err)
574
+
}
575
+
576
+
r := loadRepo(t, ing, "did:plc:akshay", "newrkey")
577
+
if r.Name != "newname" {
578
+
t.Errorf("rename did not apply despite matching owner: name=%q", r.Name)
579
+
}
580
+
if spy.renames != 1 {
581
+
t.Errorf("RenameRepo called %d times, want 1", spy.renames)
582
+
}
583
+
}
584
+
585
+
func TestIngestRepo_CreateVerifierTransientErrorPropagates(t *testing.T) {
586
+
ing, spy := newTestIngester(t)
587
+
588
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{
589
+
Knot: "knot.example",
590
+
RepoDid: ptr("did:plc:akshays-repo"),
591
+
})
592
+
593
+
withVerifier(ing, stubVerifier(repoverify.Result{}, errors.New("knot unreachable")))
594
+
595
+
err := ing.ingestRepo(context.Background(), e)
596
+
if err == nil {
597
+
t.Fatalf("expected error on transient verifier failure, got nil")
598
+
}
599
+
if spy.creates != 0 {
600
+
t.Errorf("NewRepo called %d times despite verifier error", spy.creates)
601
+
}
602
+
}
603
+
604
+
func TestIngestRepo_UpdateRejectsOwnerMismatch(t *testing.T) {
605
+
ing, _ := newTestIngester(t)
606
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo")
607
+
608
+
e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:boltless", "akshayskey", tangled.Repo{
609
+
Knot: "knot.example",
610
+
Description: ptr("boltless hijacks metadata"),
611
+
RepoDid: ptr("did:plc:akshays-repo"),
612
+
})
613
+
614
+
withVerifier(ing, stubVerifier(repoverify.Result{
615
+
RepoDid: "did:plc:akshays-repo",
616
+
OwnerDid: "did:plc:akshay",
617
+
KnotURL: mustKnotURL(t, "https://knot.example"),
618
+
}, nil))
619
+
620
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
621
+
t.Fatalf("ingestRepo: %v", err)
622
+
}
623
+
624
+
akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey")
625
+
if akshay.Description == "boltless hijacks metadata" {
626
+
t.Errorf("update by non-owner applied: %+v", akshay)
627
+
}
628
+
}
629
+
630
+
func TestIngestRepo_CreateInvalidRepoDidRejected(t *testing.T) {
631
+
ing, spy := newTestIngester(t)
632
+
633
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{
634
+
Knot: "knot.example",
635
+
RepoDid: ptr("did:plc:"),
636
+
})
637
+
638
+
verifierCalled := false
639
+
withVerifier(ing, func(_ context.Context, _ repoverify.RepoDid) (repoverify.Result, error) {
640
+
verifierCalled = true
641
+
return repoverify.Result{}, nil
642
+
})
643
+
644
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
645
+
t.Fatalf("ingestRepo: %v", err)
646
+
}
647
+
if verifierCalled {
648
+
t.Errorf("verifier was called with an invalid repoDid")
649
+
}
650
+
if spy.creates != 0 {
651
+
t.Errorf("NewRepo called %d times despite invalid repoDid", spy.creates)
652
+
}
653
+
}
654
+
655
+
func TestIngestRepo_NilVerifierFailsClosed(t *testing.T) {
656
+
ing, spy := newTestIngester(t)
657
+
658
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{
659
+
Knot: "knot.example",
660
+
RepoDid: ptr("did:plc:akshays-repo"),
661
+
})
662
+
663
+
err := ing.ingestRepo(context.Background(), e)
664
+
if err == nil {
665
+
t.Fatalf("expected error when Verifier is nil, got nil")
666
+
}
667
+
if spy.creates != 0 {
668
+
t.Errorf("NewRepo called %d times despite nil verifier", spy.creates)
669
+
}
670
+
}
671
+
672
+
func TestIngestRepo_CreateRejectsKnotMismatch(t *testing.T) {
673
+
ing, spy := newTestIngester(t)
674
+
675
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{
676
+
Knot: "evil.example",
677
+
RepoDid: ptr("did:plc:akshays-repo"),
678
+
})
679
+
680
+
withVerifier(ing, stubVerifier(repoverify.Result{
681
+
RepoDid: "did:plc:akshays-repo",
682
+
OwnerDid: "did:plc:akshay",
683
+
KnotURL: mustKnotURL(t, "https://knot.example"),
684
+
}, nil))
685
+
686
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
687
+
t.Fatalf("ingestRepo: %v", err)
688
+
}
689
+
if _, err := db.GetRepo(ing.Db,
690
+
orm.FilterEq("did", "did:plc:akshay"),
691
+
orm.FilterEq("rkey", "myrepo"),
692
+
); !errors.Is(err, sql.ErrNoRows) {
693
+
t.Fatalf("row should not be created for spoofed knot, err=%v", err)
694
+
}
695
+
if spy.creates != 0 {
696
+
t.Errorf("NewRepo called %d times despite knot mismatch", spy.creates)
697
+
}
698
+
}
699
+
700
+
func TestIngestRepo_UpdateRejectsKnotMismatch(t *testing.T) {
701
+
ing, _ := newTestIngester(t)
702
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo")
703
+
704
+
e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{
705
+
Knot: "evil.example",
706
+
Description: ptr("redirected clone target"),
707
+
RepoDid: ptr("did:plc:akshays-repo"),
708
+
})
709
+
710
+
withVerifier(ing, stubVerifier(repoverify.Result{
711
+
RepoDid: "did:plc:akshays-repo",
712
+
OwnerDid: "did:plc:akshay",
713
+
KnotURL: mustKnotURL(t, "https://knot.example"),
714
+
}, nil))
715
+
716
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
717
+
t.Fatalf("ingestRepo: %v", err)
718
+
}
719
+
akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey")
720
+
if akshay.Description == "redirected clone target" {
721
+
t.Errorf("update with spoofed knot applied: %+v", akshay)
722
+
}
723
+
if akshay.Knot != "knot.example" {
724
+
t.Errorf("row knot mutated to %q, want knot.example", akshay.Knot)
725
+
}
726
+
}
727
+
728
+
func TestIngestRepo_UpdateRejectsRepoDidMutation(t *testing.T) {
729
+
ing, _ := newTestIngester(t)
730
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo")
731
+
732
+
e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{
733
+
Knot: "knot.example",
734
+
Description: ptr("sneaky repoDid swap"),
735
+
RepoDid: ptr("did:plc:other-repo"),
736
+
})
737
+
738
+
withVerifier(ing, stubVerifier(repoverify.Result{
739
+
RepoDid: "did:plc:other-repo",
740
+
OwnerDid: "did:plc:akshay",
741
+
KnotURL: mustKnotURL(t, "https://knot.example"),
742
+
}, nil))
743
+
744
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
745
+
t.Fatalf("ingestRepo: %v", err)
746
+
}
747
+
akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey")
748
+
if akshay.RepoDid != "did:plc:akshays-repo" {
749
+
t.Errorf("repoDid mutated to %q, want did:plc:akshays-repo", akshay.RepoDid)
750
+
}
751
+
if akshay.Description == "sneaky repoDid swap" {
752
+
t.Errorf("metadata from repoDid-mutating update applied: %+v", akshay)
753
+
}
754
+
}
+1
-1
appview/pulls/comment.go
+1
-1
appview/pulls/comment.go
+8
-15
appview/pulls/create.go
+8
-15
appview/pulls/create.go
···
98
98
99
99
repoString := strings.SplitN(forkRepo, "/", 2)
100
100
forkOwnerDid := repoString[0]
101
-
repoName := repoString[1]
102
-
fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName)
101
+
forkRkey := strings.ToLower(repoString[1])
102
+
fork, err := db.GetForkByDid(s.db, forkOwnerDid, forkRkey)
103
103
if errors.Is(err, sql.ErrNoRows) {
104
104
s.pages.Notice(w, "pull", "No such fork.")
105
105
return
106
106
} else if err != nil {
107
-
l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "repo_name", repoName)
107
+
l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "fork_rkey", forkRkey)
108
108
s.pages.Notice(w, "pull", "Failed to fetch fork.")
109
109
return
110
110
}
···
182
182
return
183
183
}
184
184
185
-
forkAtUri := fork.RepoAt()
186
-
var forkDid *syntax.DID
187
-
if fork.RepoDid != "" {
188
-
forkDid = new(syntax.DID)
189
-
*forkDid = syntax.DID(fork.RepoDid)
190
-
}
191
-
185
+
forkDid := syntax.DID(fork.RepoDid)
192
186
pullSource := &models.PullSource{
193
187
Branch: sourceBranch,
194
-
RepoAt: &forkAtUri,
195
-
RepoDid: forkDid,
188
+
RepoDid: &forkDid,
196
189
}
197
190
198
191
s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies)
···
284
277
Body: body,
285
278
TargetBranch: targetBranch,
286
279
OwnerDid: userDid.String(),
287
-
RepoAt: repo.RepoAt(),
280
+
RepoDid: syntax.DID(repo.RepoDid),
288
281
Rkey: rkey,
289
282
Mentions: mentions,
290
283
References: references,
···
323
316
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
324
317
return
325
318
}
326
-
pullId, err := db.NextPullId(tx, repo.RepoAt())
319
+
pullId, err := db.NextPullId(tx, repo.RepoDid)
327
320
if err != nil {
328
321
s.logger.Error("failed to get pull id", "err", err)
329
322
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
501
494
Body: body,
502
495
TargetBranch: targetBranch,
503
496
OwnerDid: userDid.String(),
504
-
RepoAt: repo.RepoAt(),
497
+
RepoDid: syntax.DID(repo.RepoDid),
505
498
Rkey: rkey,
506
499
Mentions: mentions,
507
500
References: references,
+2
-2
appview/pulls/lifecycle.go
+2
-2
appview/pulls/lifecycle.go
···
66
66
}
67
67
err = db.ClosePulls(
68
68
tx,
69
-
orm.FilterEq("repo_at", f.RepoAt()),
69
+
orm.FilterEq("repo_did", string(f.RepoDid)),
70
70
orm.FilterIn("at_uri", atUris),
71
71
)
72
72
if err != nil {
···
143
143
}
144
144
err = db.ReopenPulls(
145
145
tx,
146
-
orm.FilterEq("repo_at", f.RepoAt()),
146
+
orm.FilterEq("repo_did", string(f.RepoDid)),
147
147
orm.FilterIn("at_uri", atUris),
148
148
)
149
149
if err != nil {
+6
-6
appview/pulls/list.go
+6
-6
appview/pulls/list.go
···
103
103
searchOpts := models.PullSearchOptions{
104
104
Keywords: tf.Keywords,
105
105
Phrases: tf.Phrases,
106
-
RepoAt: f.RepoAt().String(),
106
+
RepoDid: f.RepoDid,
107
107
State: state,
108
108
AuthorDid: authorDid,
109
109
Labels: labels,
···
175
175
}
176
176
} else {
177
177
filters := []orm.Filter{
178
-
orm.FilterEq("repo_at", f.RepoAt()),
178
+
orm.FilterEq("repo_did", f.RepoDid),
179
179
}
180
180
if state != nil {
181
181
filters = append(filters, orm.FilterEq("state", *state))
···
195
195
for _, p := range pulls {
196
196
var pullSourceRepo *models.Repo
197
197
if p.PullSource != nil {
198
-
if p.PullSource.RepoAt != nil {
199
-
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
198
+
if p.PullSource.RepoDid != nil {
199
+
pullSourceRepo, err = db.GetRepoByDid(s.db, string(*p.PullSource.RepoDid))
200
200
if err != nil {
201
-
l.Error("failed to get repo by at uri", "err", err, "repo_at", p.PullSource.RepoAt.String())
201
+
l.Error("failed to get repo by did", "err", err, "repo_did", p.PullSource.RepoDid.String())
202
202
continue
203
203
} else {
204
204
p.PullSource.Repo = pullSourceRepo
···
265
265
s.db,
266
266
len(shas),
267
267
orm.FilterEq("p.repo_owner", f.Did),
268
-
orm.FilterEq("p.repo_name", f.Name),
268
+
orm.FilterEq("p.repo_name", f.Rkey),
269
269
orm.FilterEq("p.knot", f.Knot),
270
270
orm.FilterIn("p.sha", shas),
271
271
)
+1
-1
appview/pulls/merge.go
+1
-1
appview/pulls/merge.go
···
118
118
atUris = append(atUris, p.AtUri())
119
119
p.State = models.PullMerged
120
120
}
121
-
err = db.MergePulls(tx, orm.FilterEq("repo_at", f.RepoAt()), orm.FilterIn("at_uri", atUris))
121
+
err = db.MergePulls(tx, orm.FilterEq("repo_did", string(f.RepoDid)), orm.FilterIn("at_uri", atUris))
122
122
if err != nil {
123
123
l.Error("failed to update pull request status in database", "err", err)
124
124
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-3031
appview/pulls/pulls.go
-3031
appview/pulls/pulls.go
···
3
3
import (
4
4
"bytes"
5
5
"compress/gzip"
6
-
"context"
7
-
"database/sql"
8
-
"encoding/json"
9
-
"errors"
10
6
"fmt"
11
7
"io"
12
-
"iter"
13
8
"log/slog"
14
-
"net/http"
15
-
"net/url"
16
-
"slices"
17
-
"sort"
18
-
"strconv"
19
-
"strings"
20
-
"time"
21
9
22
-
"tangled.org/core/api/tangled"
23
10
"tangled.org/core/appview/config"
24
11
"tangled.org/core/appview/db"
25
12
pulls_indexer "tangled.org/core/appview/indexer/pulls"
···
28
15
"tangled.org/core/appview/notify"
29
16
"tangled.org/core/appview/oauth"
30
17
"tangled.org/core/appview/pages"
31
-
"tangled.org/core/appview/pages/markup"
32
-
"tangled.org/core/appview/pages/repoinfo"
33
-
"tangled.org/core/appview/pagination"
34
18
"tangled.org/core/appview/reporesolver"
35
-
"tangled.org/core/appview/searchquery"
36
19
"tangled.org/core/appview/validator"
37
-
"tangled.org/core/appview/xrpcclient"
38
20
"tangled.org/core/idresolver"
39
21
"tangled.org/core/ogre"
40
-
"tangled.org/core/orm"
41
-
"tangled.org/core/patchutil"
42
22
"tangled.org/core/rbac"
43
-
"tangled.org/core/tid"
44
-
"tangled.org/core/types"
45
-
"tangled.org/core/xrpc"
46
23
47
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
48
-
"github.com/bluesky-social/indigo/atproto/atclient"
49
-
"github.com/bluesky-social/indigo/atproto/syntax"
50
-
lexutil "github.com/bluesky-social/indigo/lex/util"
51
24
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
52
-
"github.com/go-chi/chi/v5"
53
25
)
54
26
55
27
const ApplicationGzip = "application/gzip"
···
109
81
return &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host)}
110
82
}
111
83
112
-
// htmx fragment
113
-
func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) {
114
-
l := s.logger.With("handler", "PullActions")
115
-
116
-
switch r.Method {
117
-
case http.MethodGet:
118
-
user := s.oauth.GetMultiAccountUser(r)
119
-
if user != nil {
120
-
l = l.With("user", user.Did)
121
-
}
122
-
123
-
f, err := s.repoResolver.Resolve(r)
124
-
if err != nil {
125
-
l.Error("failed to get repo and knot", "err", err)
126
-
return
127
-
}
128
-
129
-
pull, ok := r.Context().Value("pull").(*models.Pull)
130
-
if !ok {
131
-
l.Error("failed to get pull")
132
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
133
-
return
134
-
}
135
-
l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
136
-
137
-
// can be nil if this pull is not stacked
138
-
stack, _ := r.Context().Value("stack").(models.Stack)
139
-
140
-
roundNumberStr := chi.URLParam(r, "round")
141
-
roundNumber, err := strconv.Atoi(roundNumberStr)
142
-
if err != nil {
143
-
roundNumber = pull.LastRoundNumber()
144
-
}
145
-
if roundNumber >= len(pull.Submissions) {
146
-
http.Error(w, "bad round id", http.StatusBadRequest)
147
-
l.Error("failed to parse round id", "err", err, "round_number", roundNumber)
148
-
return
149
-
}
150
-
151
-
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
152
-
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
153
-
resubmitResult := pages.Unknown
154
-
if user.Did == pull.OwnerDid {
155
-
resubmitResult = s.resubmitCheck(r, f, pull, stack)
156
-
}
157
-
158
-
s.pages.PullActionsFragment(w, pages.PullActionsParams{
159
-
LoggedInUser: user,
160
-
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
161
-
Pull: pull,
162
-
RoundNumber: roundNumber,
163
-
MergeCheck: mergeCheckResponse,
164
-
ResubmitCheck: resubmitResult,
165
-
BranchDeleteStatus: branchDeleteStatus,
166
-
Stack: stack,
167
-
})
168
-
return
169
-
}
170
-
}
171
-
172
-
func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) {
173
-
l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff)
174
-
175
-
user := s.oauth.GetMultiAccountUser(r)
176
-
if user != nil {
177
-
l = l.With("user", user.Did)
178
-
}
179
-
180
-
f, err := s.repoResolver.Resolve(r)
181
-
if err != nil {
182
-
l.Error("failed to get repo and knot", "err", err)
183
-
return
184
-
}
185
-
186
-
pull, ok := r.Context().Value("pull").(*models.Pull)
187
-
if !ok {
188
-
l.Error("failed to get pull")
189
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
190
-
return
191
-
}
192
-
l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
193
-
194
-
backlinks, err := db.GetBacklinks(s.db, pull.AtUri())
195
-
if err != nil {
196
-
l.Error("failed to get pull backlinks", "err", err)
197
-
s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.")
198
-
return
199
-
}
200
-
201
-
roundId := chi.URLParam(r, "round")
202
-
roundIdInt := pull.LastRoundNumber()
203
-
if r, err := strconv.Atoi(roundId); err == nil {
204
-
roundIdInt = r
205
-
}
206
-
if roundIdInt >= len(pull.Submissions) {
207
-
http.Error(w, "bad round id", http.StatusBadRequest)
208
-
l.Error("failed to parse round id", "err", err, "round_number", roundIdInt)
209
-
return
210
-
}
211
-
212
-
var diffOpts types.DiffOpts
213
-
if d := r.URL.Query().Get("diff"); d == "split" {
214
-
diffOpts.Split = true
215
-
}
216
-
217
-
// can be nil if this pull is not stacked
218
-
stack, _ := r.Context().Value("stack").(models.Stack)
219
-
220
-
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
221
-
branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
222
-
resubmitResult := pages.Unknown
223
-
if user != nil && user.Did == pull.OwnerDid {
224
-
resubmitResult = s.resubmitCheck(r, f, pull, stack)
225
-
}
226
-
227
-
m := make(map[string]models.Pipeline)
228
-
229
-
var shas []string
230
-
for _, s := range pull.Submissions {
231
-
shas = append(shas, s.SourceRev)
232
-
}
233
-
for _, p := range stack {
234
-
shas = append(shas, p.LatestSha())
235
-
}
236
-
237
-
ps, err := db.GetPipelineStatuses(
238
-
s.db,
239
-
len(shas),
240
-
orm.FilterEq("p.repo_owner", f.Did),
241
-
orm.FilterEq("p.repo_name", f.Rkey),
242
-
orm.FilterEq("p.knot", f.Knot),
243
-
orm.FilterIn("p.sha", shas),
244
-
)
245
-
if err != nil {
246
-
l.Error("failed to fetch pipeline statuses", "err", err)
247
-
// non-fatal
248
-
}
249
-
250
-
for _, p := range ps {
251
-
m[p.Sha] = p
252
-
}
253
-
254
-
reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
255
-
if err != nil {
256
-
l.Error("failed to get pull reactions", "err", err)
257
-
}
258
-
259
-
userReactions := map[models.ReactionKind]bool{}
260
-
if user != nil {
261
-
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
262
-
}
263
-
264
-
labelDefs, err := db.GetLabelDefinitions(
265
-
s.db,
266
-
orm.FilterIn("at_uri", f.Labels),
267
-
orm.FilterContains("scope", tangled.RepoPullNSID),
268
-
)
269
-
if err != nil {
270
-
l.Error("failed to fetch labels", "err", err)
271
-
s.pages.Error503(w)
272
-
return
273
-
}
274
-
275
-
defs := make(map[string]*models.LabelDefinition)
276
-
for _, l := range labelDefs {
277
-
defs[l.AtUri().String()] = &l
278
-
}
279
-
280
-
vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
281
-
if user != nil {
282
-
participants := pull.Participants()
283
-
vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants)
284
-
if err != nil {
285
-
l.Error("failed to fetch vouch relationships", "err", err)
286
-
}
287
-
}
288
-
289
-
patch := pull.Submissions[roundIdInt].CombinedPatch()
290
-
var diff types.DiffRenderer
291
-
diff = patchutil.AsNiceDiff(patch, pull.TargetBranch)
292
-
293
-
if interdiff {
294
-
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
295
-
if err != nil {
296
-
l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt)
297
-
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
298
-
return
299
-
}
300
-
301
-
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
302
-
if err != nil {
303
-
l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt)
304
-
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
305
-
return
306
-
}
307
-
308
-
diff = patchutil.Interdiff(previousPatch, currentPatch)
309
-
}
310
-
311
-
err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
312
-
LoggedInUser: user,
313
-
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
314
-
Pull: pull,
315
-
Stack: stack,
316
-
Backlinks: backlinks,
317
-
BranchDeleteStatus: branchDeleteStatus,
318
-
MergeCheck: mergeCheckResponse,
319
-
ResubmitCheck: resubmitResult,
320
-
Pipelines: m,
321
-
Diff: diff,
322
-
DiffOpts: diffOpts,
323
-
ActiveRound: roundIdInt,
324
-
IsInterdiff: interdiff,
325
-
326
-
Reactions: reactionMap,
327
-
UserReacted: userReactions,
328
-
329
-
LabelDefs: defs,
330
-
VouchRelationships: vouchRelationships,
331
-
})
332
-
if err != nil {
333
-
l.Error("failed to render page", "err", err)
334
-
}
335
-
}
336
-
337
-
func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
338
-
l := s.logger.With("handler", "RepoSinglePull")
339
-
340
-
pull, ok := r.Context().Value("pull").(*models.Pull)
341
-
if !ok {
342
-
l.Error("failed to get pull")
343
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
344
-
return
345
-
}
346
-
347
-
http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound)
348
-
}
349
-
350
-
func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
351
-
if pull.State == models.PullMerged {
352
-
return types.MergeCheckResponse{}
353
-
}
354
-
355
-
xrpcc := s.knotClient(f.Knot)
356
-
357
-
// combine patches of substack
358
-
subStack := stack.Below(pull)
359
-
// collect the portion of the stack that is mergeable
360
-
mergeable := subStack.Mergeable()
361
-
// combine each patch
362
-
patch := mergeable.CombinedPatch()
363
-
364
-
resp, err := tangled.RepoMergeCheck(
365
-
r.Context(),
366
-
xrpcc,
367
-
&tangled.RepoMergeCheck_Input{
368
-
Did: f.Did,
369
-
Name: f.Name,
370
-
Branch: pull.TargetBranch,
371
-
Patch: patch,
372
-
},
373
-
)
374
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
375
-
s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
376
-
return types.MergeCheckResponse{
377
-
Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()),
378
-
}
379
-
}
380
-
381
-
return mergeCheckResponseFrom(resp)
382
-
}
383
-
384
-
func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse {
385
-
conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
386
-
for i, c := range resp.Conflicts {
387
-
conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason}
388
-
}
389
-
out := types.MergeCheckResponse{
390
-
IsConflicted: resp.Is_conflicted,
391
-
Conflicts: conflicts,
392
-
}
393
-
if resp.Message != nil {
394
-
out.Message = *resp.Message
395
-
}
396
-
if resp.Error != nil {
397
-
out.Error = *resp.Error
398
-
}
399
-
return out
400
-
}
401
-
402
-
func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus {
403
-
if pull.State != models.PullMerged {
404
-
return nil
405
-
}
406
-
407
-
user := s.oauth.GetMultiAccountUser(r)
408
-
if user == nil {
409
-
return nil
410
-
}
411
-
412
-
var branch string
413
-
// check if the branch exists
414
-
// NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
415
-
if pull.IsBranchBased() {
416
-
branch = pull.PullSource.Branch
417
-
} else if pull.IsForkBased() {
418
-
branch = pull.PullSource.Branch
419
-
repo = pull.PullSource.Repo
420
-
} else {
421
-
return nil
422
-
}
423
-
424
-
// deleted fork
425
-
if repo == nil {
426
-
return nil
427
-
}
428
-
429
-
// user can only delete branch if they are a collaborator in the repo that the branch belongs to
430
-
perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier())
431
-
if !slices.Contains(perms, "repo:push") {
432
-
return nil
433
-
}
434
-
435
-
xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
436
-
resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoAt().String())
437
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
438
-
s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err)
439
-
return nil
440
-
}
441
-
442
-
return &models.BranchDeleteStatus{
443
-
Repo: repo,
444
-
Branch: resp.Name,
445
-
}
446
-
}
447
-
448
-
func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
449
-
if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil {
450
-
return pages.Unknown
451
-
}
452
-
453
-
var sourceRepoDid string
454
-
if pull.PullSource.RepoDid != nil {
455
-
sourceRepoDid = string(*pull.PullSource.RepoDid)
456
-
} else {
457
-
sourceRepoDid = repo.RepoDid
458
-
}
459
-
460
-
xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
461
-
branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid)
462
-
if err != nil {
463
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
464
-
s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch)
465
-
return pages.Unknown
466
-
}
467
-
s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId)
468
-
return pages.Unknown
469
-
}
470
-
471
-
targetBranch := branchResp
472
-
473
-
top := stack[0]
474
-
latestSourceRev := top.LatestSha()
475
-
476
-
if latestSourceRev != targetBranch.Hash {
477
-
return pages.ShouldResubmit
478
-
}
479
-
480
-
return pages.ShouldNotResubmit
481
-
}
482
-
483
-
func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
484
-
s.repoPullHelper(w, r, false)
485
-
}
486
-
487
-
func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
488
-
s.repoPullHelper(w, r, true)
489
-
}
490
-
491
-
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
492
-
l := s.logger.With("handler", "RepoPullPatchRaw")
493
-
494
-
pull, ok := r.Context().Value("pull").(*models.Pull)
495
-
if !ok {
496
-
l.Error("failed to get pull")
497
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
498
-
return
499
-
}
500
-
l = l.With("pull_id", pull.PullId)
501
-
502
-
roundId := chi.URLParam(r, "round")
503
-
roundIdInt, err := strconv.Atoi(roundId)
504
-
if err != nil || roundIdInt >= len(pull.Submissions) {
505
-
http.Error(w, "bad round id", http.StatusBadRequest)
506
-
l.Error("failed to parse round id", "err", err, "round_id_str", roundId)
507
-
return
508
-
}
509
-
510
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
511
-
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
512
-
}
513
-
514
-
func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
515
-
l := s.logger.With("handler", "RepoPulls")
516
-
517
-
user := s.oauth.GetMultiAccountUser(r)
518
-
if user != nil {
519
-
l = l.With("user", user.Did)
520
-
}
521
-
522
-
params := r.URL.Query()
523
-
page := pagination.FromContext(r.Context())
524
-
525
-
f, err := s.repoResolver.Resolve(r)
526
-
if err != nil {
527
-
l.Error("failed to get repo and knot", "err", err)
528
-
return
529
-
}
530
-
l = l.With("repo_at", f.RepoAt().String())
531
-
532
-
query := searchquery.Parse(params.Get("q"))
533
-
534
-
var state *models.PullState
535
-
if urlState := params.Get("state"); urlState != "" {
536
-
switch urlState {
537
-
case "open":
538
-
state = ptrPullState(models.PullOpen)
539
-
case "closed":
540
-
state = ptrPullState(models.PullClosed)
541
-
case "merged":
542
-
state = ptrPullState(models.PullMerged)
543
-
}
544
-
query.Set("state", urlState)
545
-
} else if queryState := query.Get("state"); queryState != nil {
546
-
switch *queryState {
547
-
case "open":
548
-
state = ptrPullState(models.PullOpen)
549
-
case "closed":
550
-
state = ptrPullState(models.PullClosed)
551
-
case "merged":
552
-
state = ptrPullState(models.PullMerged)
553
-
}
554
-
} else if _, hasQ := params["q"]; !hasQ {
555
-
state = ptrPullState(models.PullOpen)
556
-
query.Set("state", "open")
557
-
}
558
-
559
-
resolve := func(ctx context.Context, ident string) (string, error) {
560
-
id, err := s.idResolver.ResolveIdent(ctx, ident)
561
-
if err != nil {
562
-
return "", err
563
-
}
564
-
return id.DID.String(), nil
565
-
}
566
-
567
-
authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l)
568
-
569
-
labels := query.GetAll("label")
570
-
negatedLabels := query.GetAllNegated("label")
571
-
labelValues := query.GetDynamicTags()
572
-
negatedLabelValues := query.GetNegatedDynamicTags()
573
-
574
-
// resolve DID-format label values: if a dynamic tag's label
575
-
// definition has format "did", resolve the handle to a DID
576
-
if len(labelValues) > 0 || len(negatedLabelValues) > 0 {
577
-
labelDefs, err := db.GetLabelDefinitions(
578
-
s.db,
579
-
orm.FilterIn("at_uri", f.Labels),
580
-
orm.FilterContains("scope", tangled.RepoPullNSID),
581
-
)
582
-
if err == nil {
583
-
didLabels := make(map[string]bool)
584
-
for _, def := range labelDefs {
585
-
if def.ValueType.Format == models.ValueTypeFormatDid {
586
-
didLabels[def.Name] = true
587
-
}
588
-
}
589
-
labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l)
590
-
negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l)
591
-
} else {
592
-
l.Debug("failed to fetch label definitions for DID resolution", "err", err)
593
-
}
594
-
}
595
-
596
-
tf := searchquery.ExtractTextFilters(query)
597
-
598
-
searchOpts := models.PullSearchOptions{
599
-
Keywords: tf.Keywords,
600
-
Phrases: tf.Phrases,
601
-
RepoDid: f.RepoDid,
602
-
State: state,
603
-
AuthorDid: authorDid,
604
-
Labels: labels,
605
-
LabelValues: labelValues,
606
-
NegatedKeywords: tf.NegatedKeywords,
607
-
NegatedPhrases: tf.NegatedPhrases,
608
-
NegatedLabels: negatedLabels,
609
-
NegatedLabelValues: negatedLabelValues,
610
-
NegatedAuthorDids: negatedAuthorDids,
611
-
Page: page,
612
-
}
613
-
614
-
var totalPulls int
615
-
if state == nil {
616
-
totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed
617
-
} else {
618
-
switch *state {
619
-
case models.PullOpen:
620
-
totalPulls = f.RepoStats.PullCount.Open
621
-
case models.PullMerged:
622
-
totalPulls = f.RepoStats.PullCount.Merged
623
-
case models.PullClosed:
624
-
totalPulls = f.RepoStats.PullCount.Closed
625
-
}
626
-
}
627
-
628
-
repoInfo := s.repoResolver.GetRepoInfo(r, user)
629
-
630
-
var pulls []*models.Pull
631
-
632
-
if searchOpts.HasSearchFilters() {
633
-
res, err := s.indexer.Search(r.Context(), searchOpts)
634
-
if err != nil {
635
-
l.Error("failed to search for pulls", "err", err)
636
-
return
637
-
}
638
-
totalPulls = int(res.Total)
639
-
l.Debug("searched pulls with indexer", "count", len(res.Hits))
640
-
641
-
// update tab counts to reflect filtered results
642
-
countOpts := searchOpts
643
-
countOpts.Page = pagination.Page{Limit: 1}
644
-
for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} {
645
-
countOpts.State = &ps
646
-
countRes, err := s.indexer.Search(r.Context(), countOpts)
647
-
if err != nil {
648
-
continue
649
-
}
650
-
switch ps {
651
-
case models.PullOpen:
652
-
repoInfo.Stats.PullCount.Open = int(countRes.Total)
653
-
case models.PullMerged:
654
-
repoInfo.Stats.PullCount.Merged = int(countRes.Total)
655
-
case models.PullClosed:
656
-
repoInfo.Stats.PullCount.Closed = int(countRes.Total)
657
-
}
658
-
}
659
-
660
-
if len(res.Hits) > 0 {
661
-
pulls, err = db.GetPulls(
662
-
s.db,
663
-
orm.FilterIn("id", res.Hits),
664
-
)
665
-
if err != nil {
666
-
l.Error("failed to get pulls", "err", err)
667
-
s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
668
-
return
669
-
}
670
-
}
671
-
} else {
672
-
filters := []orm.Filter{
673
-
orm.FilterEq("repo_did", f.RepoDid),
674
-
}
675
-
if state != nil {
676
-
filters = append(filters, orm.FilterEq("state", *state))
677
-
}
678
-
pulls, err = db.GetPullsPaginated(
679
-
s.db,
680
-
page,
681
-
filters...,
682
-
)
683
-
if err != nil {
684
-
l.Error("failed to get pulls", "err", err)
685
-
s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
686
-
return
687
-
}
688
-
}
689
-
690
-
for _, p := range pulls {
691
-
var pullSourceRepo *models.Repo
692
-
if p.PullSource != nil {
693
-
if p.PullSource.RepoDid != nil {
694
-
pullSourceRepo, err = db.GetRepoByDid(s.db, string(*p.PullSource.RepoDid))
695
-
if err != nil {
696
-
l.Error("failed to get repo by did", "err", err, "repo_did", p.PullSource.RepoDid.String())
697
-
continue
698
-
} else {
699
-
p.PullSource.Repo = pullSourceRepo
700
-
}
701
-
}
702
-
}
703
-
}
704
-
705
-
var stacks []models.Stack
706
-
var shas []string
707
-
708
-
pullMap := make(map[string]*models.Pull)
709
-
for _, p := range pulls {
710
-
shas = append(shas, p.LatestSha())
711
-
pullMap[p.AtUri().String()] = p
712
-
}
713
-
714
-
// track which PRs have been added to stacks
715
-
visited := make(map[string]bool)
716
-
717
-
// group stacked PRs together using dependent_on relationships
718
-
for _, p := range pulls {
719
-
if visited[p.AtUri().String()] {
720
-
continue
721
-
}
722
-
723
-
root := p
724
-
for root.DependentOn != nil {
725
-
if parent, ok := pullMap[root.DependentOn.String()]; ok {
726
-
root = parent
727
-
} else {
728
-
break // parent not in current page
729
-
}
730
-
}
731
-
732
-
var stack models.Stack
733
-
current := root
734
-
for {
735
-
if visited[current.AtUri().String()] {
736
-
break
737
-
}
738
-
stack = append(stack, current)
739
-
visited[current.AtUri().String()] = true
740
-
741
-
found := false
742
-
for _, candidate := range pulls {
743
-
if candidate.DependentOn != nil &&
744
-
candidate.DependentOn.String() == current.AtUri().String() {
745
-
current = candidate
746
-
found = true
747
-
break
748
-
}
749
-
}
750
-
if !found {
751
-
break
752
-
}
753
-
}
754
-
755
-
slices.Reverse(stack)
756
-
stacks = append(stacks, stack)
757
-
}
758
-
759
-
ps, err := db.GetPipelineStatuses(
760
-
s.db,
761
-
len(shas),
762
-
orm.FilterEq("p.repo_owner", f.Did),
763
-
orm.FilterEq("p.repo_name", f.Rkey),
764
-
orm.FilterEq("p.knot", f.Knot),
765
-
orm.FilterIn("p.sha", shas),
766
-
)
767
-
if err != nil {
768
-
l.Warn("failed to fetch pipeline statuses", "err", err)
769
-
// non-fatal
770
-
}
771
-
m := make(map[string]models.Pipeline)
772
-
for _, p := range ps {
773
-
m[p.Sha] = p
774
-
}
775
-
776
-
labelDefs, err := db.GetLabelDefinitions(
777
-
s.db,
778
-
orm.FilterIn("at_uri", f.Labels),
779
-
orm.FilterContains("scope", tangled.RepoPullNSID),
780
-
)
781
-
if err != nil {
782
-
l.Error("failed to fetch labels", "err", err)
783
-
s.pages.Error503(w)
784
-
return
785
-
}
786
-
787
-
defs := make(map[string]*models.LabelDefinition)
788
-
for _, l := range labelDefs {
789
-
defs[l.AtUri().String()] = &l
790
-
}
791
-
792
-
filterState := ""
793
-
if state != nil {
794
-
filterState = state.String()
795
-
}
796
-
797
-
vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
798
-
if user != nil {
799
-
dids := make([]syntax.DID, len(pulls))
800
-
for i, p := range pulls {
801
-
dids[i] = syntax.DID(p.OwnerDid)
802
-
}
803
-
vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), dids)
804
-
if err != nil {
805
-
l.Error("failed to fetch vouch relationships", "err", err)
806
-
}
807
-
}
808
-
809
-
err = s.pages.RepoPulls(w, pages.RepoPullsParams{
810
-
LoggedInUser: s.oauth.GetMultiAccountUser(r),
811
-
RepoInfo: repoInfo,
812
-
Pulls: pulls,
813
-
LabelDefs: defs,
814
-
FilterState: filterState,
815
-
FilterQuery: query.String(),
816
-
Stacks: stacks,
817
-
Pipelines: m,
818
-
Page: page,
819
-
PullCount: totalPulls,
820
-
VouchRelationships: vouchRelationships,
821
-
})
822
-
if err != nil {
823
-
l.Error("failed to render page", "err", err)
824
-
}
825
-
}
826
-
827
-
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
828
-
l := s.logger.With("handler", "PullComment")
829
-
830
-
user := s.oauth.GetMultiAccountUser(r)
831
-
if user != nil {
832
-
l = l.With("user", user.Did)
833
-
}
834
-
835
-
f, err := s.repoResolver.Resolve(r)
836
-
if err != nil {
837
-
l.Error("failed to get repo and knot", "err", err)
838
-
return
839
-
}
840
-
841
-
pull, ok := r.Context().Value("pull").(*models.Pull)
842
-
if !ok {
843
-
l.Error("failed to get pull")
844
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
845
-
return
846
-
}
847
-
l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
848
-
849
-
roundNumberStr := chi.URLParam(r, "round")
850
-
roundNumber, err := strconv.Atoi(roundNumberStr)
851
-
if err != nil || roundNumber >= len(pull.Submissions) {
852
-
http.Error(w, "bad round id", http.StatusBadRequest)
853
-
l.Error("failed to parse round id", "err", err, "round_number_str", roundNumberStr)
854
-
return
855
-
}
856
-
857
-
switch r.Method {
858
-
case http.MethodGet:
859
-
s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
860
-
LoggedInUser: user,
861
-
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
862
-
Pull: pull,
863
-
RoundNumber: roundNumber,
864
-
})
865
-
return
866
-
case http.MethodPost:
867
-
body := r.FormValue("body")
868
-
if body == "" {
869
-
s.pages.Notice(w, "pull", "Comment body is required")
870
-
return
871
-
}
872
-
873
-
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
874
-
875
-
// Start a transaction
876
-
tx, err := s.db.BeginTx(r.Context(), nil)
877
-
if err != nil {
878
-
l.Error("failed to start transaction", "err", err)
879
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
880
-
return
881
-
}
882
-
defer tx.Rollback()
883
-
884
-
createdAt := time.Now().Format(time.RFC3339)
885
-
886
-
client, err := s.oauth.AuthorizedClient(r)
887
-
if err != nil {
888
-
l.Error("failed to get authorized client", "err", err)
889
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
890
-
return
891
-
}
892
-
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
893
-
Collection: tangled.RepoPullCommentNSID,
894
-
Repo: user.Did,
895
-
Rkey: tid.TID(),
896
-
Record: &lexutil.LexiconTypeDecoder{
897
-
Val: &tangled.RepoPullComment{
898
-
Pull: pull.AtUri().String(),
899
-
Body: body,
900
-
CreatedAt: createdAt,
901
-
},
902
-
},
903
-
})
904
-
if err != nil {
905
-
l.Error("failed to create pull comment", "err", err)
906
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
907
-
return
908
-
}
909
-
910
-
comment := &models.PullComment{
911
-
OwnerDid: user.Did,
912
-
RepoDid: string(f.RepoDid),
913
-
PullId: pull.PullId,
914
-
Body: body,
915
-
CommentAt: atResp.Uri,
916
-
SubmissionId: pull.Submissions[roundNumber].ID,
917
-
Mentions: mentions,
918
-
References: references,
919
-
}
920
-
921
-
// Create the pull comment in the database with the commentAt field
922
-
commentId, err := db.NewPullComment(tx, comment)
923
-
if err != nil {
924
-
l.Error("failed to create pull comment in database", "err", err)
925
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
926
-
return
927
-
}
928
-
929
-
// Commit the transaction
930
-
if err = tx.Commit(); err != nil {
931
-
l.Error("failed to commit transaction", "err", err)
932
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
933
-
return
934
-
}
935
-
936
-
s.notifier.NewPullComment(r.Context(), comment, mentions)
937
-
938
-
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
939
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId))
940
-
return
941
-
}
942
-
}
943
-
944
-
func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) {
945
-
l := s.logger.With("handler", "NewPull")
946
-
947
-
user := s.oauth.GetMultiAccountUser(r)
948
-
if user != nil {
949
-
l = l.With("user", user.Did)
950
-
}
951
-
952
-
f, err := s.repoResolver.Resolve(r)
953
-
if err != nil {
954
-
l.Error("failed to get repo and knot", "err", err)
955
-
return
956
-
}
957
-
l = l.With("repo_at", f.RepoAt().String())
958
-
959
-
switch r.Method {
960
-
case http.MethodGet:
961
-
params, err := s.composeParams(r, f)
962
-
if err != nil {
963
-
l.Error("failed to build compose params", "err", err)
964
-
s.pages.Error503(w)
965
-
return
966
-
}
967
-
s.pages.RepoNewPull(w, params)
968
-
969
-
case http.MethodPost:
970
-
title := r.FormValue("title")
971
-
body := r.FormValue("body")
972
-
targetBranch := r.FormValue("targetBranch")
973
-
fromFork := r.FormValue("fork")
974
-
sourceBranch := r.FormValue("sourceBranch")
975
-
patch := r.FormValue("patch")
976
-
userDid := syntax.DID(user.Did)
977
-
978
-
if targetBranch == "" {
979
-
s.pages.Notice(w, "pull", "Target branch is required.")
980
-
return
981
-
}
982
-
983
-
// Determine PR type based on input parameters
984
-
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(userDid.String(), f.Knot, f.RepoIdentifier())}
985
-
isPushAllowed := roles.IsPushAllowed()
986
-
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
987
-
isForkBased := fromFork != "" && sourceBranch != ""
988
-
isPatchBased := patch != "" && !isBranchBased && !isForkBased
989
-
isStacked := r.FormValue("mode") == "stack" && !isPatchBased
990
-
991
-
if isPatchBased && !patchutil.IsFormatPatch(patch) {
992
-
if title == "" {
993
-
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
994
-
return
995
-
}
996
-
sanitizer := markup.NewSanitizer()
997
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" {
998
-
s.pages.Notice(w, "pull", "Title is empty after HTML sanitization")
999
-
return
1000
-
}
1001
-
}
1002
-
1003
-
// Validate we have at least one valid PR creation method
1004
-
if !isBranchBased && !isPatchBased && !isForkBased {
1005
-
s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
1006
-
return
1007
-
}
1008
-
1009
-
// Can't mix branch-based and patch-based approaches
1010
-
if isBranchBased && patch != "" {
1011
-
s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
1012
-
return
1013
-
}
1014
-
1015
-
if isBranchBased && sourceBranch == targetBranch {
1016
-
s.pages.Notice(w, "pull", "Source and target branch must be different.")
1017
-
return
1018
-
}
1019
-
1020
-
// us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1021
-
// if err != nil {
1022
-
// log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
1023
-
// s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
1024
-
// return
1025
-
// }
1026
-
1027
-
// TODO: make capabilities an xrpc call
1028
-
caps := struct {
1029
-
PullRequests struct {
1030
-
FormatPatch bool
1031
-
BranchSubmissions bool
1032
-
ForkSubmissions bool
1033
-
PatchSubmissions bool
1034
-
}
1035
-
}{
1036
-
PullRequests: struct {
1037
-
FormatPatch bool
1038
-
BranchSubmissions bool
1039
-
ForkSubmissions bool
1040
-
PatchSubmissions bool
1041
-
}{
1042
-
FormatPatch: true,
1043
-
BranchSubmissions: true,
1044
-
ForkSubmissions: true,
1045
-
PatchSubmissions: true,
1046
-
},
1047
-
}
1048
-
1049
-
// caps, err := us.Capabilities()
1050
-
// if err != nil {
1051
-
// log.Println("error fetching knot caps", f.Knot, err)
1052
-
// s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
1053
-
// return
1054
-
// }
1055
-
1056
-
if !caps.PullRequests.FormatPatch {
1057
-
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
1058
-
return
1059
-
}
1060
-
1061
-
stackTitles := parseBracketedForm(r.Form, "stackTitle")
1062
-
stackBodies := parseBracketedForm(r.Form, "stackBody")
1063
-
1064
-
// Handle the PR creation based on the type
1065
-
if isBranchBased {
1066
-
if !caps.PullRequests.BranchSubmissions {
1067
-
s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
1068
-
return
1069
-
}
1070
-
s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies)
1071
-
} else if isForkBased {
1072
-
if !caps.PullRequests.ForkSubmissions {
1073
-
s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
1074
-
return
1075
-
}
1076
-
s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies)
1077
-
} else if isPatchBased {
1078
-
if !caps.PullRequests.PatchSubmissions {
1079
-
s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
1080
-
return
1081
-
}
1082
-
s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies)
1083
-
}
1084
-
return
1085
-
}
1086
-
}
1087
-
1088
-
func (s *Pulls) handleBranchBasedPull(
1089
-
w http.ResponseWriter,
1090
-
r *http.Request,
1091
-
repo *models.Repo,
1092
-
userDid syntax.DID,
1093
-
title,
1094
-
body,
1095
-
targetBranch,
1096
-
sourceBranch string,
1097
-
isStacked bool,
1098
-
stackTitles, stackBodies map[string]string,
1099
-
) {
1100
-
l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked)
1101
-
1102
-
xrpcc := s.knotClient(repo.Knot)
1103
-
1104
-
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch)
1105
-
if err != nil {
1106
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1107
-
l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err)
1108
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1109
-
return
1110
-
}
1111
-
l.Error("failed to compare", "err", err)
1112
-
s.pages.Notice(w, "pull", err.Error())
1113
-
return
1114
-
}
1115
-
1116
-
var comparison types.RepoFormatPatchResponse
1117
-
if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
1118
-
l.Error("failed to decode XRPC compare response", "err", err)
1119
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1120
-
return
1121
-
}
1122
-
1123
-
if len(comparison.FormatPatch) == 0 {
1124
-
s.pages.Notice(w, "pull", "No commits between target and source.")
1125
-
return
1126
-
}
1127
-
1128
-
sourceRev := comparison.Rev2
1129
-
patch := comparison.FormatPatchRaw
1130
-
combined := comparison.CombinedPatchRaw
1131
-
1132
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1133
-
s.logger.Error("failed to validate patch", "err", err)
1134
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1135
-
return
1136
-
}
1137
-
1138
-
pullSource := &models.PullSource{
1139
-
Branch: sourceBranch,
1140
-
}
1141
-
1142
-
s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies)
1143
-
}
1144
-
1145
-
func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) {
1146
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1147
-
s.logger.Error("patch validation failed", "err", err)
1148
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1149
-
return
1150
-
}
1151
-
1152
-
s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies)
1153
-
}
1154
-
1155
-
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) {
1156
-
l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo", forkRepo, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked)
1157
-
1158
-
repoString := strings.SplitN(forkRepo, "/", 2)
1159
-
forkOwnerDid := repoString[0]
1160
-
forkRkey := strings.ToLower(repoString[1])
1161
-
fork, err := db.GetForkByDid(s.db, forkOwnerDid, forkRkey)
1162
-
if errors.Is(err, sql.ErrNoRows) {
1163
-
s.pages.Notice(w, "pull", "No such fork.")
1164
-
return
1165
-
} else if err != nil {
1166
-
l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "fork_rkey", forkRkey)
1167
-
s.pages.Notice(w, "pull", "Failed to fetch fork.")
1168
-
return
1169
-
}
1170
-
1171
-
client, err := s.oauth.ServiceClient(
1172
-
r,
1173
-
oauth.WithService(fork.Knot),
1174
-
oauth.WithLxm(tangled.RepoHiddenRefNSID),
1175
-
oauth.WithDev(s.config.Core.Dev),
1176
-
)
1177
-
1178
-
resp, err := tangled.RepoHiddenRef(
1179
-
r.Context(),
1180
-
client,
1181
-
&tangled.RepoHiddenRef_Input{
1182
-
ForkRef: sourceBranch,
1183
-
RemoteRef: targetBranch,
1184
-
Repo: fork.RepoAt().String(),
1185
-
},
1186
-
)
1187
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1188
-
s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err)
1189
-
s.pages.Notice(w, "pull", xrpcerr.Error())
1190
-
return
1191
-
}
1192
-
1193
-
if !resp.Success {
1194
-
errorMsg := "Failed to create pull request"
1195
-
if resp.Error != nil {
1196
-
errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error)
1197
-
}
1198
-
s.pages.Notice(w, "pull", errorMsg)
1199
-
return
1200
-
}
1201
-
1202
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
1203
-
// We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
1204
-
// the targetBranch on the target repository. This code is a bit confusing, but here's an example:
1205
-
// hiddenRef: hidden/feature-1/main (on repo-fork)
1206
-
// targetBranch: main (on repo-1)
1207
-
// sourceBranch: feature-1 (on repo-fork)
1208
-
forkXrpcc := s.knotClient(fork.Knot)
1209
-
1210
-
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch)
1211
-
if err != nil {
1212
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1213
-
l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef)
1214
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1215
-
return
1216
-
}
1217
-
l.Error("failed to compare across branches", "err", err, "hidden_ref", hiddenRef)
1218
-
s.pages.Notice(w, "pull", err.Error())
1219
-
return
1220
-
}
1221
-
1222
-
var comparison types.RepoFormatPatchResponse
1223
-
if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil {
1224
-
l.Error("failed to decode XRPC compare response for fork", "err", err)
1225
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1226
-
return
1227
-
}
1228
-
1229
-
if len(comparison.FormatPatch) == 0 {
1230
-
s.pages.Notice(w, "pull", "No commits between target and source.")
1231
-
return
1232
-
}
1233
-
1234
-
sourceRev := comparison.Rev2
1235
-
patch := comparison.FormatPatchRaw
1236
-
combined := comparison.CombinedPatchRaw
1237
-
1238
-
if err := s.validator.ValidatePatch(&patch); err != nil {
1239
-
s.logger.Error("failed to validate patch", "err", err)
1240
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
1241
-
return
1242
-
}
1243
-
1244
-
forkDid := syntax.DID(fork.RepoDid)
1245
-
pullSource := &models.PullSource{
1246
-
Branch: sourceBranch,
1247
-
RepoDid: &forkDid,
1248
-
}
1249
-
1250
-
s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies)
1251
-
}
1252
-
1253
-
func (s *Pulls) createPullRequest(
1254
-
w http.ResponseWriter,
1255
-
r *http.Request,
1256
-
repo *models.Repo,
1257
-
userDid syntax.DID,
1258
-
title, body, targetBranch string,
1259
-
patch string,
1260
-
combined string,
1261
-
sourceRev string,
1262
-
pullSource *models.PullSource,
1263
-
isStacked bool,
1264
-
stackTitles, stackBodies map[string]string,
1265
-
) {
1266
-
l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked)
1267
-
1268
-
if isStacked {
1269
-
// creates a series of PRs, each linking to the previous, identified by jj's change-id
1270
-
s.createStackedPullRequest(
1271
-
w,
1272
-
r,
1273
-
repo,
1274
-
userDid,
1275
-
targetBranch,
1276
-
patch,
1277
-
sourceRev,
1278
-
pullSource,
1279
-
stackTitles,
1280
-
stackBodies,
1281
-
)
1282
-
return
1283
-
}
1284
-
1285
-
client, err := s.oauth.AuthorizedClient(r)
1286
-
if err != nil {
1287
-
l.Error("failed to get authorized client", "err", err)
1288
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1289
-
return
1290
-
}
1291
-
1292
-
tx, err := s.db.BeginTx(r.Context(), nil)
1293
-
if err != nil {
1294
-
l.Error("failed to start tx", "err", err)
1295
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1296
-
return
1297
-
}
1298
-
defer tx.Rollback()
1299
-
1300
-
// We've already checked earlier if it's diff-based and title is empty,
1301
-
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1302
-
if title == "" || body == "" {
1303
-
formatPatches, err := patchutil.ExtractPatches(patch)
1304
-
if err != nil {
1305
-
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1306
-
return
1307
-
}
1308
-
if len(formatPatches) == 0 {
1309
-
s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
1310
-
return
1311
-
}
1312
-
1313
-
if title == "" {
1314
-
title = formatPatches[0].Title
1315
-
}
1316
-
if body == "" {
1317
-
body = formatPatches[0].Body
1318
-
}
1319
-
}
1320
-
1321
-
mentions, references := s.mentionsResolver.Resolve(r.Context(), body)
1322
-
1323
-
rkey := tid.TID()
1324
-
1325
-
blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip)
1326
-
if err != nil {
1327
-
l.Error("failed to upload patch", "err", err)
1328
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1329
-
return
1330
-
}
1331
-
1332
-
now := time.Now()
1333
-
1334
-
pull := &models.Pull{
1335
-
Title: title,
1336
-
Body: body,
1337
-
TargetBranch: targetBranch,
1338
-
OwnerDid: userDid.String(),
1339
-
RepoDid: syntax.DID(repo.RepoDid),
1340
-
Rkey: rkey,
1341
-
Mentions: mentions,
1342
-
References: references,
1343
-
Submissions: []*models.PullSubmission{
1344
-
{
1345
-
Patch: patch,
1346
-
Combined: combined,
1347
-
SourceRev: sourceRev,
1348
-
Blob: *blob.Blob,
1349
-
Created: now,
1350
-
},
1351
-
},
1352
-
PullSource: pullSource,
1353
-
State: models.PullOpen,
1354
-
Created: now,
1355
-
}
1356
-
1357
-
record := pull.AsRecord()
1358
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1359
-
Collection: tangled.RepoPullNSID,
1360
-
Repo: userDid.String(),
1361
-
Rkey: rkey,
1362
-
Record: &lexutil.LexiconTypeDecoder{
1363
-
Val: &record,
1364
-
},
1365
-
})
1366
-
if err != nil {
1367
-
l.Error("failed to create pull request", "err", err)
1368
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1369
-
return
1370
-
}
1371
-
1372
-
err = db.PutPull(tx, pull)
1373
-
if err != nil {
1374
-
l.Error("failed to create pull request in database", "err", err)
1375
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1376
-
return
1377
-
}
1378
-
pullId, err := db.NextPullId(tx, repo.RepoDid)
1379
-
if err != nil {
1380
-
s.logger.Error("failed to get pull id", "err", err)
1381
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1382
-
return
1383
-
}
1384
-
1385
-
if err = tx.Commit(); err != nil {
1386
-
l.Error("failed to commit transaction for pull request", "err", err)
1387
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1388
-
return
1389
-
}
1390
-
1391
-
s.notifier.NewPull(r.Context(), pull)
1392
-
1393
-
s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo)
1394
-
1395
-
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1396
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId))
1397
-
}
1398
-
1399
-
func (s *Pulls) createStackedPullRequest(
1400
-
w http.ResponseWriter,
1401
-
r *http.Request,
1402
-
repo *models.Repo,
1403
-
userDid syntax.DID,
1404
-
targetBranch string,
1405
-
patch string,
1406
-
sourceRev string,
1407
-
pullSource *models.PullSource,
1408
-
stackTitles, stackBodies map[string]string,
1409
-
) {
1410
-
l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev)
1411
-
1412
-
// run some necessary checks for stacked-prs first
1413
-
1414
-
formatPatches, err := patchutil.ExtractPatches(patch)
1415
-
if err != nil {
1416
-
l.Error("failed to extract patches", "err", err)
1417
-
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1418
-
return
1419
-
}
1420
-
1421
-
// must have atleast 1 patch to begin with
1422
-
if len(formatPatches) == 0 {
1423
-
l.Error("empty patches")
1424
-
s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
1425
-
return
1426
-
}
1427
-
1428
-
client, err := s.oauth.AuthorizedClient(r)
1429
-
if err != nil {
1430
-
l.Error("failed to get authorized client", "err", err)
1431
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1432
-
return
1433
-
}
1434
-
1435
-
// first upload all blobs
1436
-
blobs := make([]*lexutil.LexBlob, len(formatPatches))
1437
-
for i, p := range formatPatches {
1438
-
blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip)
1439
-
if err != nil {
1440
-
l.Error("failed to upload patch blob", "err", err, "patch_index", i)
1441
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1442
-
return
1443
-
}
1444
-
l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches))
1445
-
blobs[i] = blob.Blob
1446
-
}
1447
-
1448
-
// build a stack out of this patch
1449
-
stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies)
1450
-
if err != nil {
1451
-
l.Error("failed to create stack", "err", err)
1452
-
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
1453
-
return
1454
-
}
1455
-
1456
-
// apply all record creations at once
1457
-
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1458
-
for _, p := range stack {
1459
-
record := p.AsRecord()
1460
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1461
-
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1462
-
Collection: tangled.RepoPullNSID,
1463
-
Rkey: &p.Rkey,
1464
-
Value: &lexutil.LexiconTypeDecoder{
1465
-
Val: &record,
1466
-
},
1467
-
},
1468
-
})
1469
-
}
1470
-
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1471
-
Repo: userDid.String(),
1472
-
Writes: writes,
1473
-
})
1474
-
if err != nil {
1475
-
l.Error("failed to create stacked pull request", "err", err)
1476
-
s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1477
-
return
1478
-
}
1479
-
1480
-
// create all pulls at once
1481
-
tx, err := s.db.BeginTx(r.Context(), nil)
1482
-
if err != nil {
1483
-
l.Error("failed to start tx", "err", err)
1484
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1485
-
return
1486
-
}
1487
-
defer tx.Rollback()
1488
-
1489
-
for _, p := range stack {
1490
-
err = db.PutPull(tx, p)
1491
-
if err != nil {
1492
-
l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey)
1493
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1494
-
return
1495
-
}
1496
-
1497
-
}
1498
-
1499
-
if err = tx.Commit(); err != nil {
1500
-
l.Error("failed to commit transaction for pull requests", "err", err)
1501
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1502
-
return
1503
-
}
1504
-
1505
-
// notify about each pull
1506
-
//
1507
-
// this is performed after tx.Commit, because it could result in a locked DB otherwise
1508
-
for _, p := range stack {
1509
-
s.notifier.NewPull(r.Context(), p)
1510
-
}
1511
-
1512
-
s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo)
1513
-
1514
-
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1515
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
1516
-
}
1517
-
1518
-
func (s *Pulls) MarkdownPreview(w http.ResponseWriter, r *http.Request) {
1519
-
body := r.FormValue("body")
1520
-
s.pages.MarkdownPreviewFragment(w, body)
1521
-
}
1522
-
1523
-
func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) {
1524
-
l := s.logger.With("handler", "RefreshCompose")
1525
-
1526
-
f, err := s.repoResolver.Resolve(r)
1527
-
if err != nil {
1528
-
l.Error("failed to resolve repo", "err", err)
1529
-
s.pages.Error503(w)
1530
-
return
1531
-
}
1532
-
1533
-
params, err := s.composeParams(r, f)
1534
-
if err != nil {
1535
-
l.Error("failed to build compose params", "err", err)
1536
-
s.pages.Error503(w)
1537
-
return
1538
-
}
1539
-
w.Header().Set("HX-Replace-Url", composeCanonicalURL(params))
1540
-
s.pages.PullComposeHostFragment(w, params)
1541
-
}
1542
-
1543
-
func composeCanonicalURL(params pages.RepoNewPullParams) string {
1544
-
base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName())
1545
-
q := url.Values{}
1546
-
if params.IsStacked {
1547
-
q.Set("mode", "stack")
1548
-
}
1549
-
if params.Source != "" && params.Source != pages.SourceBranch {
1550
-
q.Set("source", string(params.Source))
1551
-
}
1552
-
if params.SourceBranch != "" {
1553
-
q.Set("sourceBranch", params.SourceBranch)
1554
-
}
1555
-
if params.TargetBranch != "" {
1556
-
q.Set("targetBranch", params.TargetBranch)
1557
-
}
1558
-
if params.Source == pages.SourceFork && params.Fork != "" {
1559
-
q.Set("fork", params.Fork)
1560
-
}
1561
-
if len(q) == 0 {
1562
-
return base
1563
-
}
1564
-
return base + "?" + q.Encode()
1565
-
}
1566
-
1567
-
func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) {
1568
-
l := s.logger.With("handler", "composeParams")
1569
-
user := s.oauth.GetMultiAccountUser(r)
1570
-
1571
-
branches, err := s.listBranches(r.Context(), repo)
1572
-
if err != nil {
1573
-
return pages.RepoNewPullParams{}, err
1574
-
}
1575
-
1576
-
var forks []models.Repo
1577
-
if user != nil {
1578
-
forks, err = db.GetForksByDid(s.db, user.Did)
1579
-
if err != nil {
1580
-
l.Warn("failed to list user forks", "err", err, "user", user.Did)
1581
-
}
1582
-
}
1583
-
1584
-
repoInfo := s.repoResolver.GetRepoInfo(r, user)
1585
-
source, ok := pages.ParseSource(r.FormValue("source"))
1586
-
if !ok {
1587
-
source = pages.SourceBranch
1588
-
if !repoInfo.Roles.IsPushAllowed() {
1589
-
source = pages.SourceFork
1590
-
}
1591
-
}
1592
-
1593
-
sourceBranch := r.FormValue("sourceBranch")
1594
-
targetBranch := r.FormValue("targetBranch")
1595
-
fork := r.FormValue("fork")
1596
-
patch := r.FormValue("patch")
1597
-
1598
-
if source == pages.SourceFork && fork == "" && len(forks) == 1 {
1599
-
fork = fmt.Sprintf("%s/%s", forks[0].Did, forks[0].Name)
1600
-
}
1601
-
1602
-
var forkBranches []types.Branch
1603
-
var forkBranchesErr error
1604
-
if source == pages.SourceFork && fork != "" {
1605
-
forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork)
1606
-
if forkBranchesErr != nil {
1607
-
l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork)
1608
-
}
1609
-
}
1610
-
1611
-
sourceBranchList := sourceBranchChoices(branches)
1612
-
targetBranch = defaultTargetBranch(branches, targetBranch)
1613
-
sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches)
1614
-
1615
-
comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch)
1616
-
var prefillErr string
1617
-
if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil {
1618
-
prefillErr = joined.Error()
1619
-
}
1620
-
1621
-
mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison)
1622
-
1623
-
refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName())
1624
-
var diffOpts types.DiffOpts
1625
-
if r.FormValue("diff") == "split" {
1626
-
diffOpts.Split = true
1627
-
}
1628
-
diffOpts.RefreshUrl = refreshUrl
1629
-
diffOpts.Target = "#diff-area"
1630
-
1631
-
labelDefs, err := s.pullLabelDefs(repo)
1632
-
if err != nil {
1633
-
l.Warn("failed to load label definitions", "err", err)
1634
-
}
1635
-
labelState := labelStateFromForm(r.Form, labelDefs)
1636
-
perCidLabelForms := parseStackLabelForms(r.Form)
1637
-
stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms))
1638
-
for cid, perForm := range perCidLabelForms {
1639
-
stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs)
1640
-
}
1641
-
1642
-
stackTitles := parseBracketedForm(r.Form, "stackTitle")
1643
-
stackBodies := parseBracketedForm(r.Form, "stackBody")
1644
-
stackSplits := parseBracketedForm(r.Form, "stackSplit")
1645
-
1646
-
title := r.FormValue("title")
1647
-
body := r.FormValue("body")
1648
-
if comparison != nil && len(comparison.FormatPatch) > 0 {
1649
-
first := comparison.FormatPatch[0]
1650
-
if title == "" && first.PatchHeader != nil {
1651
-
title = first.Title
1652
-
}
1653
-
if body == "" && first.PatchHeader != nil {
1654
-
body = first.Body
1655
-
}
1656
-
}
1657
-
1658
-
isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch
1659
-
var stackedDiffs []pages.StackedDiff
1660
-
if isStacked {
1661
-
stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits)
1662
-
}
1663
-
1664
-
return pages.RepoNewPullParams{
1665
-
LoggedInUser: user,
1666
-
RepoInfo: repoInfo,
1667
-
Branches: branches,
1668
-
SourceBranches: sourceBranchList,
1669
-
ForkBranches: forkBranches,
1670
-
Forks: forks,
1671
-
Source: source,
1672
-
SourceBranch: sourceBranch,
1673
-
TargetBranch: targetBranch,
1674
-
Fork: fork,
1675
-
Patch: patch,
1676
-
Title: title,
1677
-
Body: body,
1678
-
IsStacked: isStacked,
1679
-
Comparison: comparison,
1680
-
Diff: diff,
1681
-
DiffOpts: diffOpts,
1682
-
StackedDiffs: stackedDiffs,
1683
-
MergeCheck: mergeCheck,
1684
-
StackTitles: stackTitles,
1685
-
StackBodies: stackBodies,
1686
-
PrefillError: prefillErr,
1687
-
LabelDefs: labelDefs,
1688
-
LabelState: labelState,
1689
-
StackLabelStates: stackLabelStates,
1690
-
}, nil
1691
-
}
1692
-
1693
-
func (s *Pulls) pullLabelDefs(repo *models.Repo) (map[string]*models.LabelDefinition, error) {
1694
-
defs, err := db.GetLabelDefinitions(
1695
-
s.db,
1696
-
orm.FilterIn("at_uri", repo.Labels),
1697
-
orm.FilterContains("scope", tangled.RepoPullNSID),
1698
-
)
1699
-
if err != nil {
1700
-
return nil, err
1701
-
}
1702
-
1703
-
out := make(map[string]*models.LabelDefinition, len(defs))
1704
-
for i := range defs {
1705
-
d := defs[i]
1706
-
if !slices.Contains(d.Scope, tangled.RepoPullNSID) {
1707
-
continue
1708
-
}
1709
-
out[d.AtUri().String()] = &d
1710
-
}
1711
-
return out, nil
1712
-
}
1713
-
1714
-
func formLabelEntries(form url.Values, defs map[string]*models.LabelDefinition) iter.Seq2[string, string] {
1715
-
return func(yield func(string, string) bool) {
1716
-
for key := range defs {
1717
-
for _, v := range form[key] {
1718
-
if v == "" {
1719
-
continue
1720
-
}
1721
-
if !yield(key, v) {
1722
-
return
1723
-
}
1724
-
}
1725
-
}
1726
-
}
1727
-
}
1728
-
1729
-
func labelStateFromForm(form url.Values, defs map[string]*models.LabelDefinition) models.LabelState {
1730
-
state := models.NewLabelState()
1731
-
actx := &models.LabelApplicationCtx{Defs: defs}
1732
-
for key, val := range formLabelEntries(form, defs) {
1733
-
_ = actx.ApplyLabelOp(state, models.LabelOp{
1734
-
Operation: models.LabelOperationAdd,
1735
-
OperandKey: key,
1736
-
OperandValue: val,
1737
-
})
1738
-
}
1739
-
return state
1740
-
}
1741
-
1742
-
func buildCreationLabelOps(
1743
-
userDid syntax.DID,
1744
-
subject syntax.ATURI,
1745
-
rkey string,
1746
-
form url.Values,
1747
-
defs map[string]*models.LabelDefinition,
1748
-
performedAt time.Time,
1749
-
) []models.LabelOp {
1750
-
var ops []models.LabelOp
1751
-
for key, val := range formLabelEntries(form, defs) {
1752
-
ops = append(ops, models.LabelOp{
1753
-
Did: userDid.String(),
1754
-
Rkey: rkey,
1755
-
Subject: subject,
1756
-
Operation: models.LabelOperationAdd,
1757
-
OperandKey: key,
1758
-
OperandValue: val,
1759
-
PerformedAt: performedAt,
1760
-
})
1761
-
}
1762
-
return ops
1763
-
}
1764
-
1765
-
func (s *Pulls) applyCreationLabels(
1766
-
ctx context.Context,
1767
-
client *atclient.APIClient,
1768
-
userDid syntax.DID,
1769
-
pulls []*models.Pull,
1770
-
form url.Values,
1771
-
repo *models.Repo,
1772
-
) {
1773
-
l := s.logger.With("handler", "applyCreationLabels", "user", userDid)
1774
-
1775
-
defs, err := s.pullLabelDefs(repo)
1776
-
if err != nil {
1777
-
l.Warn("failed to fetch label defs", "err", err)
1778
-
return
1779
-
}
1780
-
if len(defs) == 0 {
1781
-
return
1782
-
}
1783
-
1784
-
perCidForms := parseStackLabelForms(form)
1785
-
1786
-
applyAll := form.Get("applyLabelsToAll") == "on"
1787
-
var firstStackForm url.Values
1788
-
if applyAll && len(pulls) > 0 && len(pulls[0].Submissions) > 0 {
1789
-
if firstCid := pulls[0].Submissions[0].ChangeId(); firstCid != "" {
1790
-
if f, ok := perCidForms[firstCid]; ok {
1791
-
firstStackForm = f
1792
-
}
1793
-
}
1794
-
}
1795
-
1796
-
performedAt := time.Now()
1797
-
for _, pull := range pulls {
1798
-
labelForm := form
1799
-
if firstStackForm != nil {
1800
-
labelForm = firstStackForm
1801
-
} else if len(perCidForms) > 0 && len(pull.Submissions) > 0 {
1802
-
if cid := pull.Submissions[0].ChangeId(); cid != "" {
1803
-
if perForm, ok := perCidForms[cid]; ok {
1804
-
labelForm = perForm
1805
-
}
1806
-
}
1807
-
}
1808
-
rkey := tid.TID()
1809
-
raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt)
1810
-
1811
-
valid := make([]models.LabelOp, 0, len(raw))
1812
-
for _, op := range raw {
1813
-
def := defs[op.OperandKey]
1814
-
if err := s.validator.ValidateLabelOp(def, repo, &op); err != nil {
1815
-
l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey)
1816
-
continue
1817
-
}
1818
-
valid = append(valid, op)
1819
-
}
1820
-
if len(valid) == 0 {
1821
-
continue
1822
-
}
1823
-
1824
-
record := models.LabelOpsAsRecord(valid)
1825
-
if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
1826
-
Collection: tangled.LabelOpNSID,
1827
-
Repo: userDid.String(),
1828
-
Rkey: rkey,
1829
-
Record: &lexutil.LexiconTypeDecoder{Val: &record},
1830
-
}); err != nil {
1831
-
l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri())
1832
-
continue
1833
-
}
1834
-
1835
-
if err := s.indexLabelOps(ctx, valid); err != nil {
1836
-
l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri())
1837
-
if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{
1838
-
Collection: tangled.LabelOpNSID,
1839
-
Repo: userDid.String(),
1840
-
Rkey: rkey,
1841
-
}); err != nil {
1842
-
l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri())
1843
-
}
1844
-
continue
1845
-
}
1846
-
1847
-
s.notifier.NewPullLabelOp(ctx, pull)
1848
-
}
1849
-
}
1850
-
1851
-
func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error {
1852
-
tx, err := s.db.BeginTx(ctx, nil)
1853
-
if err != nil {
1854
-
return err
1855
-
}
1856
-
defer tx.Rollback()
1857
-
for _, op := range ops {
1858
-
if _, err := db.AddLabelOp(tx, &op); err != nil {
1859
-
return err
1860
-
}
1861
-
}
1862
-
return tx.Commit()
1863
-
}
1864
-
1865
-
func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) {
1866
-
xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
1867
-
xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoAt().String())
1868
-
if err != nil {
1869
-
return nil, err
1870
-
}
1871
-
var result types.RepoBranchesResponse
1872
-
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1873
-
return nil, err
1874
-
}
1875
-
return result.Branches, nil
1876
-
}
1877
-
1878
-
func (s *Pulls) listForkBranches(ctx context.Context, forkIdent string) ([]types.Branch, error) {
1879
-
parts := strings.SplitN(forkIdent, "/", 2)
1880
-
if len(parts) != 2 {
1881
-
return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent)
1882
-
}
1883
-
forkRepo, err := db.GetRepo(s.db, orm.FilterEq("did", parts[0]), orm.FilterEq("name", parts[1]))
1884
-
if err != nil {
1885
-
return nil, err
1886
-
}
1887
-
branches, err := s.listBranches(ctx, forkRepo)
1888
-
if err != nil {
1889
-
return nil, err
1890
-
}
1891
-
return sortBranchesByRecency(branches), nil
1892
-
}
1893
-
1894
-
func sourceBranchChoices(branches []types.Branch) []types.Branch {
1895
-
withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool {
1896
-
return b.IsDefault
1897
-
})
1898
-
return sortBranchesByRecency(withoutDefault)
1899
-
}
1900
-
1901
-
func defaultTargetBranch(branches []types.Branch, current string) string {
1902
-
if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) {
1903
-
return current
1904
-
}
1905
-
if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 {
1906
-
return branches[idx].Reference.Name
1907
-
}
1908
-
return ""
1909
-
}
1910
-
1911
-
func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string {
1912
-
var candidates []types.Branch
1913
-
switch source {
1914
-
case pages.SourceFork:
1915
-
candidates = forkBranches
1916
-
case pages.SourceBranch:
1917
-
candidates = branchChoices
1918
-
default:
1919
-
return current
1920
-
}
1921
-
if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) {
1922
-
return current
1923
-
}
1924
-
if len(candidates) == 0 {
1925
-
return ""
1926
-
}
1927
-
return candidates[0].Reference.Name
1928
-
}
1929
-
1930
-
func sortBranchesByRecency(branches []types.Branch) []types.Branch {
1931
-
out := slices.Clone(branches)
1932
-
sort.SliceStable(out, func(i, j int) bool {
1933
-
if out[i].Commit == nil || out[j].Commit == nil {
1934
-
return out[i].Commit != nil
1935
-
}
1936
-
return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When)
1937
-
})
1938
-
return out
1939
-
}
1940
-
1941
-
func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) {
1942
-
var (
1943
-
comparison *types.RepoFormatPatchResponse
1944
-
err error
1945
-
)
1946
-
switch source {
1947
-
case pages.SourcePatch:
1948
-
if strings.TrimSpace(patch) == "" {
1949
-
return nil, nil, nil
1950
-
}
1951
-
if verr := s.validator.ValidatePatch(&patch); verr != nil {
1952
-
return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch")
1953
-
}
1954
-
comparison = parsePastedPatch(patch)
1955
-
case pages.SourceBranch:
1956
-
if targetBranch == "" || sourceBranch == "" {
1957
-
return nil, nil, nil
1958
-
}
1959
-
comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch)
1960
-
case pages.SourceFork:
1961
-
if fork == "" || targetBranch == "" || sourceBranch == "" {
1962
-
return nil, nil, nil
1963
-
}
1964
-
comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch)
1965
-
default:
1966
-
return nil, nil, nil
1967
-
}
1968
-
if err != nil {
1969
-
s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source)
1970
-
return nil, nil, err
1971
-
}
1972
-
1973
-
return comparison, deriveDiff(comparison, targetBranch), nil
1974
-
}
1975
-
1976
-
func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse {
1977
-
if comparison == nil || targetBranch == "" {
1978
-
return nil
1979
-
}
1980
-
patch := comparison.CombinedPatchRaw
1981
-
if patch == "" {
1982
-
patch = comparison.FormatPatchRaw
1983
-
}
1984
-
if patch == "" {
1985
-
return nil
1986
-
}
1987
-
1988
-
xrpcc := s.knotClient(repo.Knot)
1989
-
1990
-
resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{
1991
-
Did: repo.Did,
1992
-
Name: repo.Name,
1993
-
Branch: targetBranch,
1994
-
Patch: patch,
1995
-
})
1996
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1997
-
s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch)
1998
-
return &types.MergeCheckResponse{Error: xrpcerr.Error()}
1999
-
}
2000
-
2001
-
out := mergeCheckResponseFrom(resp)
2002
-
return &out
2003
-
}
2004
-
2005
-
func bracketComponents(key, prefix string) ([]string, bool) {
2006
-
if !strings.HasPrefix(key, prefix) {
2007
-
return nil, false
2008
-
}
2009
-
rest := key[len(prefix):]
2010
-
var parts []string
2011
-
for len(rest) > 0 {
2012
-
if !strings.HasPrefix(rest, "[") {
2013
-
return nil, false
2014
-
}
2015
-
end := strings.Index(rest, "]")
2016
-
if end <= 0 {
2017
-
return nil, false
2018
-
}
2019
-
parts = append(parts, rest[1:end])
2020
-
rest = rest[end+1:]
2021
-
}
2022
-
if len(parts) == 0 {
2023
-
return nil, false
2024
-
}
2025
-
return parts, true
2026
-
}
2027
-
2028
-
func parseBracketedForm(form url.Values, prefix string) map[string]string {
2029
-
out := make(map[string]string)
2030
-
for key, vals := range form {
2031
-
parts, ok := bracketComponents(key, prefix)
2032
-
if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 {
2033
-
continue
2034
-
}
2035
-
out[parts[0]] = vals[0]
2036
-
}
2037
-
return out
2038
-
}
2039
-
2040
-
func parseStackLabelForms(form url.Values) map[string]url.Values {
2041
-
out := make(map[string]url.Values)
2042
-
for key, vals := range form {
2043
-
parts, ok := bracketComponents(key, "stackLabel")
2044
-
if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" {
2045
-
continue
2046
-
}
2047
-
cid, atUri := parts[0], parts[1]
2048
-
if _, ok := out[cid]; !ok {
2049
-
out[cid] = make(url.Values)
2050
-
}
2051
-
out[cid][atUri] = append(out[cid][atUri], vals...)
2052
-
}
2053
-
return out
2054
-
}
2055
-
2056
-
func parsePastedPatch(patch string) *types.RepoFormatPatchResponse {
2057
-
if patch == "" {
2058
-
return nil
2059
-
}
2060
-
response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch}
2061
-
if patchutil.IsFormatPatch(patch) {
2062
-
if patches, err := patchutil.ExtractPatches(patch); err == nil {
2063
-
response.FormatPatch = patches
2064
-
}
2065
-
}
2066
-
return response
2067
-
}
2068
-
2069
-
func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) {
2070
-
xrpcc := s.knotClient(repo.Knot)
2071
-
2072
-
xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch)
2073
-
if err != nil {
2074
-
return nil, err
2075
-
}
2076
-
2077
-
var comparison types.RepoFormatPatchResponse
2078
-
if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
2079
-
return nil, err
2080
-
}
2081
-
return &comparison, nil
2082
-
}
2083
-
2084
-
func (s *Pulls) fetchForkComparison(r *http.Request, forkIdent, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) {
2085
-
parts := strings.SplitN(forkIdent, "/", 2)
2086
-
if len(parts) != 2 {
2087
-
return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent)
2088
-
}
2089
-
fork, err := db.GetForkByDid(s.db, parts[0], parts[1])
2090
-
if err != nil {
2091
-
return nil, err
2092
-
}
2093
-
2094
-
client, err := s.oauth.ServiceClient(
2095
-
r,
2096
-
oauth.WithService(fork.Knot),
2097
-
oauth.WithLxm(tangled.RepoHiddenRefNSID),
2098
-
oauth.WithDev(s.config.Core.Dev),
2099
-
)
2100
-
if err != nil {
2101
-
return nil, err
2102
-
}
2103
-
2104
-
resp, err := tangled.RepoHiddenRef(
2105
-
r.Context(),
2106
-
client,
2107
-
&tangled.RepoHiddenRef_Input{
2108
-
ForkRef: sourceBranch,
2109
-
RemoteRef: targetBranch,
2110
-
Repo: fork.RepoAt().String(),
2111
-
},
2112
-
)
2113
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2114
-
return nil, xrpcerr
2115
-
}
2116
-
if !resp.Success {
2117
-
if resp.Error != nil {
2118
-
return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error)
2119
-
}
2120
-
return nil, fmt.Errorf("hidden ref failed")
2121
-
}
2122
-
2123
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
2124
-
forkXrpcc := s.knotClient(fork.Knot)
2125
-
2126
-
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch)
2127
-
if err != nil {
2128
-
return nil, err
2129
-
}
2130
-
2131
-
var comparison types.RepoFormatPatchResponse
2132
-
if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil {
2133
-
return nil, err
2134
-
}
2135
-
return &comparison, nil
2136
-
}
2137
-
2138
-
func stackPerCommitDiffs(
2139
-
comparison *types.RepoFormatPatchResponse,
2140
-
targetBranch, refreshUrl string,
2141
-
stackSplits map[string]string,
2142
-
) []pages.StackedDiff {
2143
-
if comparison == nil {
2144
-
return nil
2145
-
}
2146
-
out := make([]pages.StackedDiff, len(comparison.FormatPatch))
2147
-
for i, p := range comparison.FormatPatch {
2148
-
nd := patchutil.AsNiceDiff(p.Raw, targetBranch)
2149
-
out[i].Diff = &nd
2150
-
cid := p.ChangeIdOrEmpty()
2151
-
if cid == "" {
2152
-
continue
2153
-
}
2154
-
out[i].Opts = types.DiffOpts{
2155
-
Split: stackSplits[cid] == "split",
2156
-
RefreshUrl: refreshUrl,
2157
-
Target: fmt.Sprintf("#stack-diff-%s", cid),
2158
-
Field: fmt.Sprintf("stackSplit[%s]", cid),
2159
-
}
2160
-
}
2161
-
return out
2162
-
}
2163
-
2164
-
func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff {
2165
-
if comparison == nil {
2166
-
return nil
2167
-
}
2168
-
raw := comparison.CombinedPatchRaw
2169
-
if raw == "" {
2170
-
raw = comparison.FormatPatchRaw
2171
-
}
2172
-
d := patchutil.AsNiceDiff(raw, targetBranch)
2173
-
return &d
2174
-
}
2175
-
2176
-
func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
2177
-
l := s.logger.With("handler", "ResubmitPull")
2178
-
2179
-
user := s.oauth.GetMultiAccountUser(r)
2180
-
if user != nil {
2181
-
l = l.With("user", user.Did)
2182
-
}
2183
-
2184
-
pull, ok := r.Context().Value("pull").(*models.Pull)
2185
-
if !ok {
2186
-
l.Error("failed to get pull")
2187
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2188
-
return
2189
-
}
2190
-
l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
2191
-
2192
-
switch r.Method {
2193
-
case http.MethodGet:
2194
-
s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
2195
-
RepoInfo: s.repoResolver.GetRepoInfo(r, user),
2196
-
Pull: pull,
2197
-
})
2198
-
return
2199
-
case http.MethodPost:
2200
-
if pull.IsPatchBased() {
2201
-
s.resubmitPatch(w, r)
2202
-
return
2203
-
} else if pull.IsBranchBased() {
2204
-
s.resubmitBranch(w, r)
2205
-
return
2206
-
} else if pull.IsForkBased() {
2207
-
s.resubmitFork(w, r)
2208
-
return
2209
-
}
2210
-
}
2211
-
}
2212
-
2213
-
func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
2214
-
l := s.logger.With("handler", "resubmitPatch")
2215
-
2216
-
user := s.oauth.GetMultiAccountUser(r)
2217
-
if user != nil {
2218
-
l = l.With("user", user.Did)
2219
-
}
2220
-
2221
-
pull, ok := r.Context().Value("pull").(*models.Pull)
2222
-
if !ok {
2223
-
l.Error("failed to get pull")
2224
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2225
-
return
2226
-
}
2227
-
l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
2228
-
2229
-
if user == nil || user.Did != pull.OwnerDid {
2230
-
l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid)
2231
-
w.WriteHeader(http.StatusUnauthorized)
2232
-
return
2233
-
}
2234
-
2235
-
f, err := s.repoResolver.Resolve(r)
2236
-
if err != nil {
2237
-
l.Error("failed to get repo and knot", "err", err)
2238
-
return
2239
-
}
2240
-
2241
-
patch := r.FormValue("patch")
2242
-
2243
-
s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "")
2244
-
}
2245
-
2246
-
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
2247
-
l := s.logger.With("handler", "resubmitBranch")
2248
-
2249
-
user := s.oauth.GetMultiAccountUser(r)
2250
-
if user != nil {
2251
-
l = l.With("user", user.Did)
2252
-
}
2253
-
2254
-
pull, ok := r.Context().Value("pull").(*models.Pull)
2255
-
if !ok {
2256
-
l.Error("failed to get pull")
2257
-
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
2258
-
return
2259
-
}
2260
-
l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch)
2261
-
2262
-
if user == nil || user.Did != pull.OwnerDid {
2263
-
l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid)
2264
-
w.WriteHeader(http.StatusUnauthorized)
2265
-
return
2266
-
}
2267
-
2268
-
f, err := s.repoResolver.Resolve(r)
2269
-
if err != nil {
2270
-
l.Error("failed to get repo and knot", "err", err)
2271
-
return
2272
-
}
2273
-
2274
-
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())}
2275
-
if !roles.IsPushAllowed() {
2276
-
l.Warn("unauthorized user - no push permission")
2277
-
w.WriteHeader(http.StatusUnauthorized)
2278
-
return
2279
-
}
2280
-
2281
-
xrpcc := s.knotClient(f.Knot)
2282
-
2283
-
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch)
2284
-
if err != nil {
2285
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2286
-
l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err, "source_branch", pull.PullSource.Branch)
2287
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2288
-
return
2289
-
}
2290
-
l.Error("compare request failed", "err", err, "source_branch", pull.PullSource.Branch)
2291
-
s.pages.Notice(w, "resubmit-error", err.Error())
2292
-
return
2293
-
}
2294
-
2295
-
var comparison types.RepoFormatPatchResponse
2296
-
if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
2297
-
l.Error("failed to decode XRPC compare response", "err", err)
2298
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2299
-
return
2300
-
}
2301
-
2302
-
sourceRev := comparison.Rev2
2303
-
patch := comparison.FormatPatchRaw
2304
-
combined := comparison.CombinedPatchRaw
2305
-
2306
-
s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev)
2307
-
}
2308
-
2309
-
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
2310
-
l := s.logger.With("handler", "resubmitFork")
2311
-
2312
-
user := s.oauth.GetMultiAccountUser(r)
2313
-
if user != nil {
2314
-
l = l.With("user", user.Did)
2315
-
}
2316
-
2317
-
pull, ok := r.Context().Value("pull").(*models.Pull)
2318
-
if !ok {
2319
-
l.Error("failed to get pull")
2320
-
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
2321
-
return
2322
-
}
2323
-
l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch)
2324
-
2325
-
if user == nil || user.Did != pull.OwnerDid {
2326
-
l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid)
2327
-
w.WriteHeader(http.StatusUnauthorized)
2328
-
return
2329
-
}
2330
-
2331
-
f, err := s.repoResolver.Resolve(r)
2332
-
if err != nil {
2333
-
l.Error("failed to get repo and knot", "err", err)
2334
-
return
2335
-
}
2336
-
2337
-
forkRepo, err := db.GetRepoByDid(s.db, string(*pull.PullSource.RepoDid))
2338
-
if err != nil {
2339
-
l.Error("failed to get source repo", "err", err, "repo_did", pull.PullSource.RepoDid.String())
2340
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2341
-
return
2342
-
}
2343
-
2344
-
// update the hidden tracking branch to latest
2345
-
client, err := s.oauth.ServiceClient(
2346
-
r,
2347
-
oauth.WithService(forkRepo.Knot),
2348
-
oauth.WithLxm(tangled.RepoHiddenRefNSID),
2349
-
oauth.WithDev(s.config.Core.Dev),
2350
-
)
2351
-
if err != nil {
2352
-
l.Error("failed to connect to knot server", "err", err, "fork_knot", forkRepo.Knot)
2353
-
return
2354
-
}
2355
-
2356
-
resp, err := tangled.RepoHiddenRef(
2357
-
r.Context(),
2358
-
client,
2359
-
&tangled.RepoHiddenRef_Input{
2360
-
ForkRef: pull.PullSource.Branch,
2361
-
RemoteRef: pull.TargetBranch,
2362
-
Repo: forkRepo.RepoAt().String(),
2363
-
},
2364
-
)
2365
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2366
-
s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err)
2367
-
s.pages.Notice(w, "resubmit-error", xrpcerr.Error())
2368
-
return
2369
-
}
2370
-
if !resp.Success {
2371
-
l.Error("failed to update tracking ref", "err", resp.Error, "fork_ref", pull.PullSource.Branch, "remote_ref", pull.TargetBranch)
2372
-
s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
2373
-
return
2374
-
}
2375
-
2376
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
2377
-
// extract patch by performing compare
2378
-
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch)
2379
-
if err != nil {
2380
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2381
-
l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch)
2382
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2383
-
return
2384
-
}
2385
-
l.Error("failed to compare branches", "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch)
2386
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2387
-
return
2388
-
}
2389
-
2390
-
var forkComparison types.RepoFormatPatchResponse
2391
-
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
2392
-
l.Error("failed to decode XRPC compare response for fork", "err", err)
2393
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2394
-
return
2395
-
}
2396
-
2397
-
// Use the fork comparison we already made
2398
-
comparison := forkComparison
2399
-
2400
-
sourceRev := comparison.Rev2
2401
-
patch := comparison.FormatPatchRaw
2402
-
combined := comparison.CombinedPatchRaw
2403
-
2404
-
s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev)
2405
-
}
2406
-
2407
-
func (s *Pulls) resubmitPullHelper(
2408
-
w http.ResponseWriter,
2409
-
r *http.Request,
2410
-
repo *models.Repo,
2411
-
userDid syntax.DID,
2412
-
pull *models.Pull,
2413
-
patch string,
2414
-
combined string,
2415
-
sourceRev string,
2416
-
) {
2417
-
l := s.logger.With("handler", "resubmitPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
2418
-
2419
-
stack := r.Context().Value("stack").(models.Stack)
2420
-
if stack != nil && len(stack) != 1 {
2421
-
l.Info("resubmitting stacked PR", "stack_size", len(stack))
2422
-
s.resubmitStackedPullHelper(w, r, repo, userDid, pull, patch)
2423
-
return
2424
-
}
2425
-
2426
-
if err := s.validator.ValidatePatch(&patch); err != nil {
2427
-
s.pages.Notice(w, "resubmit-error", err.Error())
2428
-
return
2429
-
}
2430
-
2431
-
if patch == pull.LatestPatch() {
2432
-
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
2433
-
return
2434
-
}
2435
-
2436
-
// validate sourceRev if branch/fork based
2437
-
if pull.IsBranchBased() || pull.IsForkBased() {
2438
-
if sourceRev == pull.LatestSha() {
2439
-
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
2440
-
return
2441
-
}
2442
-
}
2443
-
2444
-
pullAt := pull.AtUri()
2445
-
newRoundNumber := len(pull.Submissions)
2446
-
newPatch := patch
2447
-
newSourceRev := sourceRev
2448
-
combinedPatch := combined
2449
-
2450
-
client, err := s.oauth.AuthorizedClient(r)
2451
-
if err != nil {
2452
-
l.Error("failed to authorize client", "err", err)
2453
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2454
-
return
2455
-
}
2456
-
2457
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, userDid.String(), pull.Rkey)
2458
-
if err != nil {
2459
-
// failed to get record
2460
-
l.Error("failed to get record from PDS", "err", err, "rkey", pull.Rkey)
2461
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
2462
-
return
2463
-
}
2464
-
2465
-
blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip)
2466
-
if err != nil {
2467
-
l.Error("failed to upload patch blob", "err", err)
2468
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2469
-
return
2470
-
}
2471
-
record := pull.AsRecord()
2472
-
record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{
2473
-
CreatedAt: time.Now().Format(time.RFC3339),
2474
-
PatchBlob: blob.Blob,
2475
-
})
2476
-
2477
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
2478
-
Collection: tangled.RepoPullNSID,
2479
-
Repo: userDid.String(),
2480
-
Rkey: pull.Rkey,
2481
-
SwapRecord: ex.Cid,
2482
-
Record: &lexutil.LexiconTypeDecoder{
2483
-
Val: &record,
2484
-
},
2485
-
})
2486
-
if err != nil {
2487
-
l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey)
2488
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2489
-
return
2490
-
}
2491
-
2492
-
err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob)
2493
-
if err != nil {
2494
-
l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber)
2495
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2496
-
return
2497
-
}
2498
-
2499
-
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
2500
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2501
-
}
2502
-
2503
-
func (s *Pulls) resubmitStackedPullHelper(
2504
-
w http.ResponseWriter,
2505
-
r *http.Request,
2506
-
repo *models.Repo,
2507
-
userDid syntax.DID,
2508
-
pull *models.Pull,
2509
-
patch string,
2510
-
) {
2511
-
l := s.logger.With("handler", "resubmitStackedPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch)
2512
-
2513
-
targetBranch := pull.TargetBranch
2514
-
2515
-
origStack, _ := r.Context().Value("stack").(models.Stack)
2516
-
2517
-
formatPatches, err := patchutil.ExtractPatches(patch)
2518
-
if err != nil {
2519
-
l.Error("failed to extract patches", "err", err)
2520
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.")
2521
-
return
2522
-
}
2523
-
2524
-
// must have atleast 1 patch to begin with
2525
-
if len(formatPatches) == 0 {
2526
-
l.Error("no patches found in the generated format-patch")
2527
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.")
2528
-
return
2529
-
}
2530
-
2531
-
client, err := s.oauth.AuthorizedClient(r)
2532
-
if err != nil {
2533
-
l.Error("failed to get authorized client", "err", err)
2534
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
2535
-
return
2536
-
}
2537
-
2538
-
// first upload all blobs
2539
-
blobs := make([]*lexutil.LexBlob, len(formatPatches))
2540
-
for i, p := range formatPatches {
2541
-
blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip)
2542
-
if err != nil {
2543
-
l.Error("failed to upload patch blob", "err", err, "patch_index", i)
2544
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
2545
-
return
2546
-
}
2547
-
l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches))
2548
-
blobs[i] = blob.Blob
2549
-
}
2550
-
2551
-
newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil)
2552
-
if err != nil {
2553
-
l.Error("failed to create resubmitted stack", "err", err)
2554
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2555
-
return
2556
-
}
2557
-
2558
-
// find the diff between the stacks, first, map them by changeId
2559
-
origById := make(map[string]*models.Pull)
2560
-
newById := make(map[string]*models.Pull)
2561
-
for _, p := range origStack {
2562
-
origById[p.LatestSubmission().ChangeId()] = p
2563
-
}
2564
-
for _, p := range newStack {
2565
-
newById[p.LatestSubmission().ChangeId()] = p
2566
-
}
2567
-
2568
-
// commits that got deleted: corresponding pull is closed
2569
-
// commits that got added: new pull is created
2570
-
// commits that got updated: corresponding pull is resubmitted & new round begins
2571
-
additions := make(map[string]*models.Pull)
2572
-
deletions := make(map[string]*models.Pull)
2573
-
updated := make(map[string]struct{})
2574
-
2575
-
// pulls in original stack but not in new one
2576
-
for _, op := range origStack {
2577
-
if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok {
2578
-
deletions[op.LatestSubmission().ChangeId()] = op
2579
-
}
2580
-
}
2581
-
2582
-
// pulls in new stack but not in original one
2583
-
for _, np := range newStack {
2584
-
if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok {
2585
-
additions[np.LatestSubmission().ChangeId()] = np
2586
-
}
2587
-
}
2588
-
2589
-
// NOTE: this loop can be written in any of above blocks,
2590
-
// but is written separately in the interest of simpler code
2591
-
for _, np := range newStack {
2592
-
if op, ok := origById[np.LatestSubmission().ChangeId()]; ok {
2593
-
// pull exists in both stacks
2594
-
updated[op.LatestSubmission().ChangeId()] = struct{}{}
2595
-
}
2596
-
}
2597
-
2598
-
// NOTE: we can go through the newStack and update dependent relations and
2599
-
// rkeys now that we know which ones have been updated
2600
-
// update dependentOn relations for the entire stack
2601
-
var parentAt *syntax.ATURI
2602
-
for _, np := range newStack {
2603
-
if op, ok := origById[np.LatestSubmission().ChangeId()]; ok {
2604
-
// pull exists in both stacks
2605
-
np.Rkey = op.Rkey
2606
-
}
2607
-
np.DependentOn = parentAt
2608
-
x := np.AtUri()
2609
-
parentAt = &x
2610
-
}
2611
-
2612
-
l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated))
2613
-
2614
-
tx, err := s.db.Begin()
2615
-
if err != nil {
2616
-
l.Error("failed to start transaction", "err", err)
2617
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2618
-
return
2619
-
}
2620
-
defer tx.Rollback()
2621
-
2622
-
// pds updates to make
2623
-
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
2624
-
2625
-
// deleted pulls are marked as deleted in the DB
2626
-
for _, p := range deletions {
2627
-
// do not do delete already merged PRs
2628
-
if p.State == models.PullMerged {
2629
-
continue
2630
-
}
2631
-
2632
-
err := db.AbandonPulls(tx, orm.FilterEq("repo_did", string(p.RepoDid)), orm.FilterEq("at_uri", p.AtUri()))
2633
-
if err != nil {
2634
-
l.Error("failed to delete pull", "err", err, "pull_id", p.PullId)
2635
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2636
-
return
2637
-
}
2638
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2639
-
RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
2640
-
Collection: tangled.RepoPullNSID,
2641
-
Rkey: p.Rkey,
2642
-
},
2643
-
})
2644
-
}
2645
-
2646
-
// new pulls are created
2647
-
for _, p := range additions {
2648
-
blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip)
2649
-
if err != nil {
2650
-
l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId())
2651
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2652
-
return
2653
-
}
2654
-
p.Submissions[0].Blob = *blob.Blob
2655
-
2656
-
if err = db.PutPull(tx, p); err != nil {
2657
-
l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId())
2658
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2659
-
return
2660
-
}
2661
-
2662
-
record := p.AsRecord()
2663
-
record.Rounds = []*tangled.RepoPull_Round{
2664
-
{
2665
-
CreatedAt: time.Now().Format(time.RFC3339),
2666
-
PatchBlob: blob.Blob,
2667
-
},
2668
-
}
2669
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2670
-
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2671
-
Collection: tangled.RepoPullNSID,
2672
-
Rkey: &p.Rkey,
2673
-
Value: &lexutil.LexiconTypeDecoder{
2674
-
Val: &record,
2675
-
},
2676
-
},
2677
-
})
2678
-
}
2679
-
2680
-
// updated pulls are, well, updated; to start a new round
2681
-
for id := range updated {
2682
-
op, _ := origById[id]
2683
-
np, _ := newById[id]
2684
-
2685
-
// do not update already merged PRs
2686
-
if op.State == models.PullMerged {
2687
-
continue
2688
-
}
2689
-
2690
-
// resubmit the new pull
2691
-
np.Rkey = op.Rkey
2692
-
pullAt := op.AtUri()
2693
-
newRoundNumber := len(op.Submissions)
2694
-
newPatch := np.LatestPatch()
2695
-
combinedPatch := np.LatestSubmission().Combined
2696
-
newSourceRev := np.LatestSha()
2697
-
2698
-
blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip)
2699
-
if err != nil {
2700
-
l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId)
2701
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2702
-
return
2703
-
}
2704
-
2705
-
// create new round
2706
-
err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob)
2707
-
if err != nil {
2708
-
l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber)
2709
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2710
-
return
2711
-
}
2712
-
2713
-
// update dependent-on relation
2714
-
if np.DependentOn != nil {
2715
-
err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri()))
2716
-
if err != nil {
2717
-
l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber)
2718
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2719
-
return
2720
-
}
2721
-
}
2722
-
2723
-
record := np.AsRecord()
2724
-
record.Rounds = op.AsRecord().Rounds
2725
-
record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{
2726
-
CreatedAt: time.Now().Format(time.RFC3339),
2727
-
PatchBlob: blob.Blob,
2728
-
})
2729
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2730
-
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2731
-
Collection: tangled.RepoPullNSID,
2732
-
Rkey: op.Rkey,
2733
-
Value: &lexutil.LexiconTypeDecoder{
2734
-
Val: &record,
2735
-
},
2736
-
},
2737
-
})
2738
-
}
2739
-
2740
-
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2741
-
Repo: userDid.String(),
2742
-
Writes: writes,
2743
-
})
2744
-
if err != nil {
2745
-
l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes))
2746
-
s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
2747
-
return
2748
-
}
2749
-
2750
-
err = tx.Commit()
2751
-
if err != nil {
2752
-
l.Error("failed to commit resubmit transaction", "err", err)
2753
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2754
-
return
2755
-
}
2756
-
2757
-
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
2758
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2759
-
}
2760
-
2761
-
func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2762
-
l := s.logger.With("handler", "MergePull")
2763
-
2764
-
user := s.oauth.GetMultiAccountUser(r)
2765
-
if user != nil {
2766
-
l = l.With("user", user.Did)
2767
-
}
2768
-
2769
-
f, err := s.repoResolver.Resolve(r)
2770
-
if err != nil {
2771
-
l.Error("failed to resolve repo", "err", err)
2772
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2773
-
return
2774
-
}
2775
-
l = l.With("repo_at", f.RepoAt().String())
2776
-
2777
-
pull, ok := r.Context().Value("pull").(*models.Pull)
2778
-
if !ok {
2779
-
l.Error("failed to get pull")
2780
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2781
-
return
2782
-
}
2783
-
l = l.With("pull_id", pull.PullId, "target_branch", pull.TargetBranch)
2784
-
2785
-
stack, ok := r.Context().Value("stack").(models.Stack)
2786
-
if !ok {
2787
-
l.Error("failed to get stack")
2788
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2789
-
return
2790
-
}
2791
-
2792
-
// combine patches of substack
2793
-
subStack := stack.Below(pull)
2794
-
// collect the portion of the stack that is mergeable
2795
-
pullsToMerge := subStack.Mergeable()
2796
-
l = l.With("pulls_to_merge", len(pullsToMerge))
2797
-
2798
-
patch := pullsToMerge.CombinedPatch()
2799
-
2800
-
ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
2801
-
if err != nil {
2802
-
l.Error("failed to resolve identity", "err", err, "owner_did", pull.OwnerDid)
2803
-
w.WriteHeader(http.StatusNotFound)
2804
-
return
2805
-
}
2806
-
2807
-
email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
2808
-
if err != nil {
2809
-
l.Warn("failed to get primary email", "err", err, "owner_did", pull.OwnerDid)
2810
-
}
2811
-
2812
-
authorName := ident.Handle.String()
2813
-
mergeInput := &tangled.RepoMerge_Input{
2814
-
Did: f.Did,
2815
-
Name: f.Name,
2816
-
Branch: pull.TargetBranch,
2817
-
Patch: patch,
2818
-
CommitMessage: &pull.Title,
2819
-
AuthorName: &authorName,
2820
-
}
2821
-
2822
-
if pull.Body != "" {
2823
-
mergeInput.CommitBody = &pull.Body
2824
-
}
2825
-
2826
-
if email.Address != "" {
2827
-
mergeInput.AuthorEmail = &email.Address
2828
-
}
2829
-
2830
-
client, err := s.oauth.ServiceClient(
2831
-
r,
2832
-
oauth.WithService(f.Knot),
2833
-
oauth.WithLxm(tangled.RepoMergeNSID),
2834
-
oauth.WithDev(s.config.Core.Dev),
2835
-
oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky
2836
-
)
2837
-
if err != nil {
2838
-
l.Error("failed to connect to knot server", "err", err, "knot", f.Knot)
2839
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2840
-
return
2841
-
}
2842
-
2843
-
err = tangled.RepoMerge(r.Context(), client, mergeInput)
2844
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2845
-
s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err)
2846
-
s.pages.Notice(w, "pull-merge-error", xrpcerr.Error())
2847
-
return
2848
-
}
2849
-
2850
-
tx, err := s.db.Begin()
2851
-
if err != nil {
2852
-
l.Error("failed to start transaction", "err", err)
2853
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2854
-
return
2855
-
}
2856
-
defer tx.Rollback()
2857
-
2858
-
var atUris []syntax.ATURI
2859
-
for _, p := range pullsToMerge {
2860
-
atUris = append(atUris, p.AtUri())
2861
-
p.State = models.PullMerged
2862
-
}
2863
-
err = db.MergePulls(tx, orm.FilterEq("repo_did", string(f.RepoDid)), orm.FilterIn("at_uri", atUris))
2864
-
if err != nil {
2865
-
l.Error("failed to update pull request status in database", "err", err)
2866
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2867
-
return
2868
-
}
2869
-
2870
-
err = tx.Commit()
2871
-
if err != nil {
2872
-
// TODO: this is unsound, we should also revert the merge from the knotserver here
2873
-
l.Error("failed to commit merge transaction", "err", err)
2874
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2875
-
return
2876
-
}
2877
-
2878
-
// notify about the pull merge
2879
-
for _, p := range pullsToMerge {
2880
-
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2881
-
}
2882
-
2883
-
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2884
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2885
-
}
2886
-
2887
-
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
2888
-
l := s.logger.With("handler", "ClosePull")
2889
-
2890
-
user := s.oauth.GetMultiAccountUser(r)
2891
-
if user != nil {
2892
-
l = l.With("user", user.Did)
2893
-
}
2894
-
2895
-
f, err := s.repoResolver.Resolve(r)
2896
-
if err != nil {
2897
-
l.Error("failed to resolve repo", "err", err)
2898
-
return
2899
-
}
2900
-
2901
-
pull, ok := r.Context().Value("pull").(*models.Pull)
2902
-
if !ok {
2903
-
l.Error("failed to get pull")
2904
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2905
-
return
2906
-
}
2907
-
l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid)
2908
-
2909
-
// auth filter: only owner or collaborators can close
2910
-
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())}
2911
-
isOwner := roles.IsOwner()
2912
-
isCollaborator := roles.IsCollaborator()
2913
-
isPullAuthor := user.Did == pull.OwnerDid
2914
-
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2915
-
if !isCloseAllowed {
2916
-
l.Error("unauthorized to close pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor)
2917
-
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2918
-
return
2919
-
}
2920
-
2921
-
// Start a transaction
2922
-
tx, err := s.db.BeginTx(r.Context(), nil)
2923
-
if err != nil {
2924
-
l.Error("failed to start transaction", "err", err)
2925
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2926
-
return
2927
-
}
2928
-
defer tx.Rollback()
2929
-
2930
-
// if this PR is stacked, then we want to close all PRs above this one on the stack
2931
-
stack := r.Context().Value("stack").(models.Stack)
2932
-
pullsToClose := stack.Above(pull)
2933
-
var atUris []syntax.ATURI
2934
-
for _, p := range pullsToClose {
2935
-
atUris = append(atUris, p.AtUri())
2936
-
p.State = models.PullClosed
2937
-
}
2938
-
err = db.ClosePulls(
2939
-
tx,
2940
-
orm.FilterEq("repo_did", string(f.RepoDid)),
2941
-
orm.FilterIn("at_uri", atUris),
2942
-
)
2943
-
if err != nil {
2944
-
l.Error("failed to close pulls in database", "err", err, "pulls_to_close", len(pullsToClose))
2945
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2946
-
}
2947
-
2948
-
// Commit the transaction
2949
-
if err = tx.Commit(); err != nil {
2950
-
l.Error("failed to commit transaction", "err", err)
2951
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
2952
-
return
2953
-
}
2954
-
2955
-
for _, p := range pullsToClose {
2956
-
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2957
-
}
2958
-
2959
-
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2960
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2961
-
}
2962
-
2963
-
func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
2964
-
l := s.logger.With("handler", "ReopenPull")
2965
-
2966
-
user := s.oauth.GetMultiAccountUser(r)
2967
-
if user != nil {
2968
-
l = l.With("user", user.Did)
2969
-
}
2970
-
2971
-
f, err := s.repoResolver.Resolve(r)
2972
-
if err != nil {
2973
-
l.Error("failed to resolve repo", "err", err)
2974
-
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2975
-
return
2976
-
}
2977
-
2978
-
pull, ok := r.Context().Value("pull").(*models.Pull)
2979
-
if !ok {
2980
-
l.Error("failed to get pull")
2981
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2982
-
return
2983
-
}
2984
-
l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State)
2985
-
2986
-
// auth filter: only owner or collaborators can close
2987
-
roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())}
2988
-
isOwner := roles.IsOwner()
2989
-
isCollaborator := roles.IsCollaborator()
2990
-
isPullAuthor := user.Did == pull.OwnerDid
2991
-
isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2992
-
if !isCloseAllowed {
2993
-
l.Error("unauthorized to reopen pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor)
2994
-
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2995
-
return
2996
-
}
2997
-
2998
-
// Start a transaction
2999
-
tx, err := s.db.BeginTx(r.Context(), nil)
3000
-
if err != nil {
3001
-
l.Error("failed to start transaction", "err", err)
3002
-
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
3003
-
return
3004
-
}
3005
-
defer tx.Rollback()
3006
-
3007
-
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
3008
-
stack := r.Context().Value("stack").(models.Stack)
3009
-
pullsToReopen := stack.Below(pull)
3010
-
var atUris []syntax.ATURI
3011
-
for _, p := range pullsToReopen {
3012
-
atUris = append(atUris, p.AtUri())
3013
-
p.State = models.PullOpen
3014
-
}
3015
-
err = db.ReopenPulls(
3016
-
tx,
3017
-
orm.FilterEq("repo_did", string(f.RepoDid)),
3018
-
orm.FilterIn("at_uri", atUris),
3019
-
)
3020
-
if err != nil {
3021
-
l.Error("failed to reopen pulls in database", "err", err, "pulls_to_reopen", len(pullsToReopen))
3022
-
s.pages.Notice(w, "pull-close", "Failed to reopen pull.")
3023
-
}
3024
-
3025
-
// Commit the transaction
3026
-
if err = tx.Commit(); err != nil {
3027
-
l.Error("failed to commit transaction", "err", err)
3028
-
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
3029
-
return
3030
-
}
3031
-
3032
-
for _, p := range pullsToReopen {
3033
-
s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
3034
-
}
3035
-
3036
-
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
3037
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
3038
-
}
3039
-
3040
-
func (s *Pulls) newStack(
3041
-
ctx context.Context,
3042
-
repo *models.Repo,
3043
-
userDid syntax.DID,
3044
-
targetBranch string,
3045
-
pullSource *models.PullSource,
3046
-
formatPatches []types.FormatPatch,
3047
-
blobs []*lexutil.LexBlob,
3048
-
stackTitles, stackBodies map[string]string,
3049
-
) (models.Stack, error) {
3050
-
var stack models.Stack
3051
-
var parentAtUri *syntax.ATURI
3052
-
for i, fp := range formatPatches {
3053
-
// all patches must have a jj change-id
3054
-
cid, err := fp.ChangeId()
3055
-
if err != nil {
3056
-
return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
3057
-
}
3058
-
3059
-
title := fp.Title
3060
-
body := fp.Body
3061
-
if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" {
3062
-
title = override
3063
-
}
3064
-
if override, ok := stackBodies[cid]; ok {
3065
-
body = override
3066
-
}
3067
-
rkey := tid.TID()
3068
-
3069
-
mentions, references := s.mentionsResolver.Resolve(ctx, body)
3070
-
3071
-
now := time.Now()
3072
-
3073
-
pull := models.Pull{
3074
-
Title: title,
3075
-
Body: body,
3076
-
TargetBranch: targetBranch,
3077
-
OwnerDid: userDid.String(),
3078
-
RepoDid: syntax.DID(repo.RepoDid),
3079
-
Rkey: rkey,
3080
-
Mentions: mentions,
3081
-
References: references,
3082
-
Submissions: []*models.PullSubmission{
3083
-
{
3084
-
Patch: fp.Raw,
3085
-
SourceRev: fp.SHA,
3086
-
Combined: fp.Raw,
3087
-
Blob: *blobs[i],
3088
-
Created: now,
3089
-
},
3090
-
},
3091
-
PullSource: pullSource,
3092
-
Created: now,
3093
-
State: models.PullOpen,
3094
-
3095
-
DependentOn: parentAtUri,
3096
-
Repo: repo,
3097
-
}
3098
-
3099
-
stack = append(stack, &pull)
3100
-
3101
-
parent := pull.AtUri()
3102
-
parentAtUri = &parent
3103
-
}
3104
-
3105
-
return stack, nil
3106
-
}
3107
-
3108
84
func gz(s string) io.Reader {
3109
85
var b bytes.Buffer
3110
86
w := gzip.NewWriter(&b)
···
3114
90
}
3115
91
3116
92
func ptrPullState(s models.PullState) *models.PullState { return &s }
3117
-
3118
-
func repoPullTarget(repo *models.Repo, branch string) *tangled.RepoPull_Target {
3119
-
return &tangled.RepoPull_Target{
3120
-
Branch: branch,
3121
-
Repo: repo.RepoDid,
3122
-
}
3123
-
}
+3
-3
appview/pulls/resubmit.go
+3
-3
appview/pulls/resubmit.go
···
185
185
return
186
186
}
187
187
188
-
forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
188
+
forkRepo, err := db.GetRepoByDid(s.db, string(*pull.PullSource.RepoDid))
189
189
if err != nil {
190
-
l.Error("failed to get source repo", "err", err, "repo_at", pull.PullSource.RepoAt.String())
190
+
l.Error("failed to get source repo", "err", err, "repo_did", pull.PullSource.RepoDid.String())
191
191
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
192
192
return
193
193
}
···
480
480
continue
481
481
}
482
482
483
-
err := db.AbandonPulls(tx, orm.FilterEq("repo_at", p.RepoAt), orm.FilterEq("at_uri", p.AtUri()))
483
+
err := db.AbandonPulls(tx, orm.FilterEq("repo_did", string(p.RepoDid)), orm.FilterEq("at_uri", p.AtUri()))
484
484
if err != nil {
485
485
l.Error("failed to delete pull", "err", err, "pull_id", p.PullId)
486
486
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
+6
-6
appview/pulls/single.go
+6
-6
appview/pulls/single.go
···
149
149
s.db,
150
150
len(shas),
151
151
orm.FilterEq("p.repo_owner", f.Did),
152
-
orm.FilterEq("p.repo_name", f.Name),
152
+
orm.FilterEq("p.repo_name", f.Rkey),
153
153
orm.FilterEq("p.knot", f.Knot),
154
154
orm.FilterIn("p.sha", shas),
155
155
)
···
361
361
return pages.Unknown
362
362
}
363
363
364
-
var sourceRepo syntax.ATURI
365
-
if pull.PullSource.RepoAt != nil {
366
-
sourceRepo = *pull.PullSource.RepoAt
364
+
var sourceRepoDid string
365
+
if pull.PullSource.RepoDid != nil {
366
+
sourceRepoDid = string(*pull.PullSource.RepoDid)
367
367
} else {
368
-
sourceRepo = repo.RepoAt()
368
+
sourceRepoDid = repo.RepoDid
369
369
}
370
370
371
371
xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
372
-
branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepo.String())
372
+
branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepoDid)
373
373
if err != nil {
374
374
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
375
375
s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch)
+155
appview/repoverify/verify.go
+155
appview/repoverify/verify.go
···
1
+
package repoverify
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"net"
7
+
"net/http"
8
+
"net/url"
9
+
"syscall"
10
+
"time"
11
+
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/xrpcclient"
16
+
"tangled.org/core/idresolver"
17
+
)
18
+
19
+
type RepoDid syntax.DID
20
+
21
+
func (r RepoDid) String() string { return string(r) }
22
+
23
+
func NewRepoDid(s string) (RepoDid, error) {
24
+
did, err := syntax.ParseDID(s)
25
+
if err != nil {
26
+
return "", fmt.Errorf("invalid repoDid %q: %w", s, err)
27
+
}
28
+
return RepoDid(did), nil
29
+
}
30
+
31
+
type OwnerDid syntax.DID
32
+
33
+
func (o OwnerDid) String() string { return string(o) }
34
+
35
+
func NewOwnerDid(s string) (OwnerDid, error) {
36
+
did, err := syntax.ParseDID(s)
37
+
if err != nil {
38
+
return "", fmt.Errorf("invalid ownerDid %q: %w", s, err)
39
+
}
40
+
return OwnerDid(did), nil
41
+
}
42
+
43
+
func ParseKnotEndpoint(raw string, dev bool) (*url.URL, error) {
44
+
if raw == "" {
45
+
return nil, fmt.Errorf("empty knot URL")
46
+
}
47
+
u, err := url.Parse(raw)
48
+
if err != nil {
49
+
return nil, fmt.Errorf("invalid knot URL %q: %w", raw, err)
50
+
}
51
+
if u.Host == "" {
52
+
return nil, fmt.Errorf("knot URL %q has no host", raw)
53
+
}
54
+
switch u.Scheme {
55
+
case "https":
56
+
case "http":
57
+
if !dev {
58
+
return nil, fmt.Errorf("knot URL %q must use https outside dev mode", raw)
59
+
}
60
+
default:
61
+
return nil, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme)
62
+
}
63
+
return u, nil
64
+
}
65
+
66
+
type Result struct {
67
+
RepoDid RepoDid
68
+
OwnerDid OwnerDid
69
+
KnotURL *url.URL
70
+
}
71
+
72
+
type Verifier func(ctx context.Context, repoDid RepoDid) (Result, error)
73
+
74
+
const verifyTimeout = 10 * time.Second
75
+
76
+
func New(resolver *idresolver.Resolver, dev bool) Verifier {
77
+
transport := &http.Transport{
78
+
DialContext: safeDialer(dev).DialContext,
79
+
}
80
+
httpClient := &http.Client{
81
+
Timeout: verifyTimeout,
82
+
Transport: transport,
83
+
}
84
+
85
+
return func(ctx context.Context, repoDid RepoDid) (Result, error) {
86
+
ctx, cancel := context.WithTimeout(ctx, verifyTimeout)
87
+
defer cancel()
88
+
return resolveAndDescribe(ctx, resolver, httpClient, repoDid, dev)
89
+
}
90
+
}
91
+
92
+
func resolveAndDescribe(
93
+
ctx context.Context,
94
+
resolver *idresolver.Resolver,
95
+
httpClient *http.Client,
96
+
repoDid RepoDid,
97
+
dev bool,
98
+
) (Result, error) {
99
+
ident, err := resolver.ResolveIdent(ctx, repoDid.String())
100
+
if err != nil {
101
+
return Result{}, fmt.Errorf("resolve repoDid %s: %w", repoDid, err)
102
+
}
103
+
104
+
knot, err := ParseKnotEndpoint(ident.GetServiceEndpoint("atproto_pds"), dev)
105
+
if err != nil {
106
+
return Result{}, fmt.Errorf("repoDid %s: %w", repoDid, err)
107
+
}
108
+
109
+
client := &indigoxrpc.Client{Host: knot.String(), Client: httpClient}
110
+
out, err := tangled.RepoDescribeRepo(ctx, client, repoDid.String())
111
+
if xrpcErr := xrpcclient.HandleXrpcErr(err); xrpcErr != nil {
112
+
return Result{}, fmt.Errorf("describeRepo on %s: %w", knot, xrpcErr)
113
+
}
114
+
115
+
if out.RepoDid != repoDid.String() {
116
+
return Result{}, fmt.Errorf("knot %s returned mismatched repoDid: got %q, want %q", knot, out.RepoDid, repoDid)
117
+
}
118
+
119
+
ownerDid, err := NewOwnerDid(out.OwnerDid)
120
+
if err != nil {
121
+
return Result{}, fmt.Errorf("describeRepo on %s returned invalid ownerDid: %w", knot, err)
122
+
}
123
+
124
+
return Result{
125
+
RepoDid: repoDid,
126
+
OwnerDid: ownerDid,
127
+
KnotURL: knot,
128
+
}, nil
129
+
}
130
+
131
+
func safeDialer(dev bool) *net.Dialer {
132
+
d := &net.Dialer{
133
+
Timeout: 5 * time.Second,
134
+
KeepAlive: 30 * time.Second,
135
+
}
136
+
if dev {
137
+
return d
138
+
}
139
+
d.Control = func(network, address string, _ syscall.RawConn) error {
140
+
host, _, err := net.SplitHostPort(address)
141
+
if err != nil {
142
+
return fmt.Errorf("invalid dial address %q: %w", address, err)
143
+
}
144
+
ip := net.ParseIP(host)
145
+
if ip == nil {
146
+
return fmt.Errorf("dial address %q did not resolve to IP", address)
147
+
}
148
+
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
149
+
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
150
+
return fmt.Errorf("refusing to dial %s: reserved or private address", ip)
151
+
}
152
+
return nil
153
+
}
154
+
return d
155
+
}
+66
appview/repoverify/verify_test.go
+66
appview/repoverify/verify_test.go
···
1
+
package repoverify
2
+
3
+
import "testing"
4
+
5
+
func TestNewRepoDid_RejectsInvalid(t *testing.T) {
6
+
if _, err := NewRepoDid(""); err == nil {
7
+
t.Error("NewRepoDid(\"\") err = nil, want error")
8
+
}
9
+
}
10
+
11
+
func TestNewRepoDid_AcceptsValid(t *testing.T) {
12
+
raw := "did:plc:abc123abc123abc123abc123"
13
+
got, err := NewRepoDid(raw)
14
+
if err != nil {
15
+
t.Fatalf("NewRepoDid(%q): %v", raw, err)
16
+
}
17
+
if got.String() != raw {
18
+
t.Errorf("got %q, want %q", got, raw)
19
+
}
20
+
}
21
+
22
+
func TestParseKnotEndpoint_RejectsHttpInProd(t *testing.T) {
23
+
if _, err := ParseKnotEndpoint("http://knot.example", false); err == nil {
24
+
t.Error("http:// knot URL accepted in prod")
25
+
}
26
+
}
27
+
28
+
func TestParseKnotEndpoint_AllowsHttpInDev(t *testing.T) {
29
+
u, err := ParseKnotEndpoint("http://knot.example", true)
30
+
if err != nil {
31
+
t.Fatalf("dev mode should allow http: %v", err)
32
+
}
33
+
if u.Host != "knot.example" {
34
+
t.Errorf("Host = %q, want knot.example", u.Host)
35
+
}
36
+
}
37
+
38
+
func TestParseKnotEndpoint_RejectsUnsupportedScheme(t *testing.T) {
39
+
if _, err := ParseKnotEndpoint("ftp://knot.example", true); err == nil {
40
+
t.Error("ParseKnotEndpoint accepted ftp:// in dev")
41
+
}
42
+
if _, err := ParseKnotEndpoint("ftp://knot.example", false); err == nil {
43
+
t.Error("ParseKnotEndpoint accepted ftp:// in prod")
44
+
}
45
+
}
46
+
47
+
func TestParseKnotEndpoint_RejectsEmptyOrHostless(t *testing.T) {
48
+
cases := []string{"", "https://", "not a url at all"}
49
+
for _, raw := range cases {
50
+
t.Run(raw, func(t *testing.T) {
51
+
if _, err := ParseKnotEndpoint(raw, false); err == nil {
52
+
t.Errorf("ParseKnotEndpoint(%q) accepted bogus URL", raw)
53
+
}
54
+
})
55
+
}
56
+
}
57
+
58
+
func TestParseKnotEndpoint_HostPreservesPort(t *testing.T) {
59
+
u, err := ParseKnotEndpoint("http://localhost:3000", true)
60
+
if err != nil {
61
+
t.Fatalf("ParseKnotEndpoint: %v", err)
62
+
}
63
+
if u.Host != "localhost:3000" {
64
+
t.Errorf("Host = %q, want localhost:3000", u.Host)
65
+
}
66
+
}
+2
appview/state/state.go
+2
appview/state/state.go
···
29
29
"tangled.org/core/appview/oauth"
30
30
"tangled.org/core/appview/pages"
31
31
"tangled.org/core/appview/reporesolver"
32
+
"tangled.org/core/appview/repoverify"
32
33
"tangled.org/core/appview/validator"
33
34
xrpcclient "tangled.org/core/appview/xrpcclient"
34
35
"tangled.org/core/consts"
···
181
182
Logger: log.SubLogger(logger, "ingester"),
182
183
Validator: validator,
183
184
Notifier: notifier,
185
+
Verifier: repoverify.New(res, config.Core.Dev),
184
186
}
185
187
err = jc.StartJetstream(ctx, ingester.Ingest())
186
188
if err != nil {
+39
knotserver/xrpc/repo_describe_repo.go
+39
knotserver/xrpc/repo_describe_repo.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"database/sql"
5
+
"errors"
6
+
"net/http"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
+
xrpcerr "tangled.org/core/xrpc/errors"
11
+
)
12
+
13
+
func (x *Xrpc) RepoDescribeRepo(w http.ResponseWriter, r *http.Request) {
14
+
raw := r.URL.Query().Get("repoDid")
15
+
repoDid, err := syntax.ParseDID(raw)
16
+
if err != nil {
17
+
writeError(w, xrpcerr.NewXrpcError(
18
+
xrpcerr.WithTag("InvalidRequest"),
19
+
xrpcerr.WithMessage("missing or invalid repoDid parameter"),
20
+
), http.StatusBadRequest)
21
+
return
22
+
}
23
+
24
+
ownerDid, rkey, err := x.Db.GetRepoKeyOwner(repoDid.String())
25
+
if errors.Is(err, sql.ErrNoRows) {
26
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNotFound)
27
+
return
28
+
}
29
+
if err != nil {
30
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
31
+
return
32
+
}
33
+
34
+
x.writeJson(w, tangled.RepoDescribeRepo_Output{
35
+
RepoDid: repoDid.String(),
36
+
OwnerDid: ownerDid,
37
+
Rkey: rkey,
38
+
})
39
+
}
+94
knotserver/xrpc/repo_describe_repo_test.go
+94
knotserver/xrpc/repo_describe_repo_test.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"io"
7
+
"log/slog"
8
+
"net/http"
9
+
"net/http/httptest"
10
+
"net/url"
11
+
"path/filepath"
12
+
"testing"
13
+
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/knotserver/config"
16
+
"tangled.org/core/knotserver/db"
17
+
)
18
+
19
+
func newTestXrpc(t *testing.T) *Xrpc {
20
+
t.Helper()
21
+
d, err := db.Setup(context.Background(), filepath.Join(t.TempDir(), "test.db"))
22
+
if err != nil {
23
+
t.Fatalf("db.Setup: %v", err)
24
+
}
25
+
return &Xrpc{
26
+
Db: d,
27
+
Config: &config.Config{Server: config.Server{Hostname: "knot.example", MaxResponseKB: 5120}},
28
+
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
29
+
}
30
+
}
31
+
32
+
func TestRepoDescribeRepo_ReturnsOwner(t *testing.T) {
33
+
x := newTestXrpc(t)
34
+
if err := x.Db.StoreRepoKey("did:plc:repo1", []byte("dummy"), "did:plc:akshay", "myrepo"); err != nil {
35
+
t.Fatalf("StoreRepoKey: %v", err)
36
+
}
37
+
38
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/sh.tangled.repo.describeRepo?repoDid=did:plc:repo1", nil)
39
+
rec := httptest.NewRecorder()
40
+
x.RepoDescribeRepo(rec, req)
41
+
42
+
if rec.Code != http.StatusOK {
43
+
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
44
+
}
45
+
46
+
var out tangled.RepoDescribeRepo_Output
47
+
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
48
+
t.Fatalf("decode: %v", err)
49
+
}
50
+
if out.RepoDid != "did:plc:repo1" {
51
+
t.Errorf("RepoDid = %q", out.RepoDid)
52
+
}
53
+
if out.OwnerDid != "did:plc:akshay" {
54
+
t.Errorf("OwnerDid = %q, want did:plc:akshay", out.OwnerDid)
55
+
}
56
+
if out.Rkey != "myrepo" {
57
+
t.Errorf("Rkey = %q, want myrepo", out.Rkey)
58
+
}
59
+
}
60
+
61
+
func TestRepoDescribeRepo_UnknownRepoDidReturns404(t *testing.T) {
62
+
x := newTestXrpc(t)
63
+
64
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/sh.tangled.repo.describeRepo?repoDid=did:plc:unknown", nil)
65
+
rec := httptest.NewRecorder()
66
+
x.RepoDescribeRepo(rec, req)
67
+
68
+
if rec.Code != http.StatusNotFound {
69
+
t.Errorf("status = %d, want 404", rec.Code)
70
+
}
71
+
}
72
+
73
+
func TestRepoDescribeRepo_MissingParamReturns400(t *testing.T) {
74
+
x := newTestXrpc(t)
75
+
76
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/sh.tangled.repo.describeRepo", nil)
77
+
rec := httptest.NewRecorder()
78
+
x.RepoDescribeRepo(rec, req)
79
+
80
+
if rec.Code != http.StatusBadRequest {
81
+
t.Errorf("status = %d, want 400", rec.Code)
82
+
}
83
+
}
84
+
85
+
func TestRepoDescribeRepo_MalformedDidParamReturns400(t *testing.T) {
86
+
x := newTestXrpc(t)
87
+
u := "/xrpc/sh.tangled.repo.describeRepo?repoDid=" + url.QueryEscape("notadid")
88
+
req := httptest.NewRequest(http.MethodGet, u, nil)
89
+
rec := httptest.NewRecorder()
90
+
x.RepoDescribeRepo(rec, req)
91
+
if rec.Code != http.StatusBadRequest {
92
+
t.Errorf("status = %d, want 400", rec.Code)
93
+
}
94
+
}
+1
knotserver/xrpc/xrpc.go
+1
knotserver/xrpc/xrpc.go
···
67
67
r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
68
68
r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
69
69
r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
70
+
r.Get("/"+tangled.RepoDescribeRepoNSID, x.RepoDescribeRepo)
70
71
r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
71
72
r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
72
73
r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
+53
lexicons/repo/describeRepo.json
+53
lexicons/repo/describeRepo.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.describeRepo",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Fetch the knot's authoritative metadata for a git repo DID.",
8
+
"parameters": {
9
+
"type": "params",
10
+
"required": ["repoDid"],
11
+
"properties": {
12
+
"repoDid": {
13
+
"type": "string",
14
+
"format": "did",
15
+
"description": "DID of the git repo as minted by the knot"
16
+
}
17
+
}
18
+
},
19
+
"output": {
20
+
"encoding": "application/json",
21
+
"schema": {
22
+
"type": "object",
23
+
"required": ["repoDid", "ownerDid", "rkey"],
24
+
"properties": {
25
+
"repoDid": {
26
+
"type": "string",
27
+
"format": "did"
28
+
},
29
+
"ownerDid": {
30
+
"type": "string",
31
+
"format": "did",
32
+
"description": "DID of the current owner according to the knot."
33
+
},
34
+
"rkey": {
35
+
"type": "string",
36
+
"description": "Current rkey of the sh.tangled.repo record tracked by this knot"
37
+
}
38
+
}
39
+
}
40
+
},
41
+
"errors": [
42
+
{
43
+
"name": "RepoNotFound",
44
+
"description": "Repo DID is not registered on this knot"
45
+
},
46
+
{
47
+
"name": "InvalidRequest",
48
+
"description": "Invalid request parameters"
49
+
}
50
+
]
51
+
}
52
+
}
53
+
}
History
9 rounds
0 comments
oyster.cafe
submitted
#8
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>
merge conflicts detected
expand
collapse
expand
collapse
- api/tangled/cbor_gen.go:866
- api/tangled/feedstar.go:5
- api/tangled/gitrefUpdate.go:29
- api/tangled/repocollaborator.go:19
- api/tangled/repoissue.go:22
- api/tangled/repopull.go:39
- api/tangled/tangledrepo.go:24
- cmd/cborgen/cborgen.go:17
- knotserver/xrpc/merge.go:118
- lexicons/feed/star.json:10
- lexicons/git/refUpdate.json:11
- lexicons/issue/issue.json:9
- lexicons/pulls/pull.json:65
- lexicons/repo/collaborator.json:11
- lexicons/repo/repo.json:6
expand 0 comments
oyster.cafe
submitted
#7
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>
expand 0 comments
oyster.cafe
submitted
#6
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>
expand 0 comments
oyster.cafe
submitted
#5
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>
expand 0 comments
oyster.cafe
submitted
#4
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>
expand 0 comments
oyster.cafe
submitted
#3
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>
expand 0 comments
oyster.cafe
submitted
#2
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>
expand 0 comments
oyster.cafe
submitted
#1
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>