diff --git a/go.mod b/go.mod index 6f0c4dd..f672bb4 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab github.com/spf13/cobra v1.10.2 + github.com/whyrusleeping/cbor-gen v0.3.1 golang.org/x/term v0.40.0 ) @@ -61,7 +62,6 @@ require ( github.com/vmihailenco/go-tinylfu v0.2.2 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/whyrusleeping/cbor-gen v0.3.1 // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -88,5 +88,5 @@ require ( github.com/pelletier/go-toml/v2 v2.3.1 github.com/spf13/pflag v1.0.9 // indirect github.com/zalando/go-keyring v0.2.8 - tangled.org/core v1.13.0-alpha.0.20260502074102-37303f21368b + tangled.org/core v1.14.0-alpha ) diff --git a/go.sum b/go.sum index 38b04a8..9e5a1ba 100644 --- a/go.sum +++ b/go.sum @@ -428,5 +428,5 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= -tangled.org/core v1.13.0-alpha.0.20260502074102-37303f21368b h1:5g3kGPrs6IGoHuFAb3kqrL5EwgGTl4ulbJTYsX1WfTM= -tangled.org/core v1.13.0-alpha.0.20260502074102-37303f21368b/go.mod h1:abXVlFoPAeM9pZier/WF1Cnn1ZoO9YE5h59WLEUy+Hk= +tangled.org/core v1.14.0-alpha h1:cAttNSWeHeyWqrxkGybBWyvYwVLLw5yerUANbgFdGz0= +tangled.org/core v1.14.0-alpha/go.mod h1:xGKzD6RYAVEwoWhE6IKJdGsoTDebsPjv0I9WsfGkYFI= diff --git a/internal/cli/workflow_test.go b/internal/cli/workflow_test.go index aed7457..b07679c 100644 --- a/internal/cli/workflow_test.go +++ b/internal/cli/workflow_test.go @@ -37,7 +37,7 @@ func TestRepositoryIssuePullAndSSHKeyCommandsWithLocalATProto(t *testing.T) { core.RepoNSID + "/r1": { URI: "at://did:plc:alice/sh.tangled.repo/r1", CID: "repo-cid", - Value: `{"$type":"sh.tangled.repo","name":"tang","knot":"` + knotHost + `","createdAt":"2026-05-02T00:00:00Z"}`, + Value: `{"$type":"sh.tangled.repo","name":"tang","knot":"` + knotHost + `","repoDid":"did:plc:repo","createdAt":"2026-05-02T00:00:00Z"}`, }, core.RepoIssueNSID + "/i1": { URI: "at://did:plc:alice/sh.tangled.repo.issue/i1", diff --git a/internal/tangled/aturi.go b/internal/tangled/aturi.go index a10f06b..9c14501 100644 --- a/internal/tangled/aturi.go +++ b/internal/tangled/aturi.go @@ -52,13 +52,40 @@ func BuildRepoATURI(ctx context.Context, context *repo.RepositoryContext) (strin } for _, record := range records.Records { value, ok := record.Value.Val.(*core.Repo) - if ok && value.Name == context.Name { + if ok && value.Name != nil && *value.Name == context.Name { return record.Uri, nil } } return "", fmt.Errorf("repository %s not found for %s", context.Name, context.Owner) } +func ResolveRepoDID(ctx context.Context, repoURI string, httpClient *http.Client) (string, error) { + parsed, err := ParseATURI(repoURI) + if err != nil { + return "", err + } + if parsed.Collection != core.RepoNSID || parsed.RKey == "" { + return "", fmt.Errorf("invalid repository AT-URI: %s", repoURI) + } + ident, err := resolveDIDFunc(ctx, parsed.DID) + if err != nil { + return "", err + } + client := NewAnonymousPDSClient(ident.PDS, httpClient) + out, err := client.GetRecord(ctx, parsed.DID, parsed.Collection, parsed.RKey) + if err != nil { + return "", err + } + record, ok := out.Value.Val.(*core.Repo) + if !ok { + return "", fmt.Errorf("record is not a repository: %s", repoURI) + } + if record.RepoDid == nil || *record.RepoDid == "" { + return "", fmt.Errorf("repository %s has no repoDid", repoURI) + } + return *record.RepoDid, nil +} + func RKeyFromURI(uri string) string { parts := strings.Split(uri, "/") if len(parts) == 0 { diff --git a/internal/tangled/backlinks.go b/internal/tangled/backlinks.go new file mode 100644 index 0000000..5aef4cb --- /dev/null +++ b/internal/tangled/backlinks.go @@ -0,0 +1,45 @@ +package tangled + +import ( + "context" + "net/http" + + "tangled.org/onev.cat/tang/internal/constellation" +) + +func repoBacklinks(ctx context.Context, constellationClient *constellation.Client, httpClient *http.Client, repoURI, collection, path string, limit int, cursor string) ([]constellation.Record, error) { + targets := []string{} + if repoDID, err := ResolveRepoDID(ctx, repoURI, httpClient); err == nil && repoDID != "" { + targets = append(targets, repoDID) + } + if !contains(targets, repoURI) { + targets = append(targets, repoURI) + } + + seen := map[string]bool{} + records := []constellation.Record{} + for _, target := range targets { + backlinks, err := constellationClient.GetBacklinks(ctx, target, collection, path, limit, cursor) + if err != nil { + return nil, err + } + for _, record := range backlinks.Records { + key := record.DID + "/" + record.Collection + "/" + record.RKey + if seen[key] { + continue + } + seen[key] = true + records = append(records, record) + } + } + return records, nil +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/tangled/issues.go b/internal/tangled/issues.go index e52a5f8..e0fba28 100644 --- a/internal/tangled/issues.go +++ b/internal/tangled/issues.go @@ -61,12 +61,12 @@ func (s *IssueService) ListIssues(ctx context.Context, repoURI string, opts Issu if limit <= 0 { limit = 50 } - backlinks, err := s.Constellation.GetBacklinks(ctx, repoURI, core.RepoIssueNSID, ".repo", limit, opts.Cursor) + links, err := repoBacklinks(ctx, s.Constellation, s.HTTPClient, repoURI, core.RepoIssueNSID, ".repo", limit, opts.Cursor) if err != nil { return nil, err } - issues := make([]Issue, 0, len(backlinks.Records)) - for _, link := range backlinks.Records { + issues := make([]Issue, 0, len(links)) + for _, link := range links { issue, err := s.getIssueByParts(ctx, link.DID, link.Collection, link.RKey) if err != nil { continue @@ -91,13 +91,17 @@ func (s *IssueService) ListIssues(ctx context.Context, repoURI string, opts Issu } func (s *IssueService) CreateIssue(ctx context.Context, session *auth.Session, repoURI, title, body string) (*Issue, error) { + repoDID, err := ResolveRepoDID(ctx, repoURI, s.HTTPClient) + if err != nil { + return nil, err + } var bodyPtr *string if body != "" { bodyPtr = &body } record := &core.RepoIssue{ LexiconTypeID: core.RepoIssueNSID, - Repo: &repoURI, + Repo: repoDID, Title: title, Body: bodyPtr, CreatedAt: time.Now().UTC().Format(time.RFC3339), @@ -111,7 +115,7 @@ func (s *IssueService) CreateIssue(ctx context.Context, session *auth.Session, r if out.Cid != "" { cid = out.Cid } - return &Issue{Title: title, Body: body, Repo: repoURI, State: "open", Author: session.DID, CreatedAt: record.CreatedAt, URI: out.Uri, CID: cid}, nil + return &Issue{Title: title, Body: body, Repo: repoDID, State: "open", Author: session.DID, CreatedAt: record.CreatedAt, URI: out.Uri, CID: cid}, nil } func (s *IssueService) GetIssue(ctx context.Context, issueURI string) (*Issue, error) { @@ -151,7 +155,7 @@ func (s *IssueService) UpdateIssue(ctx context.Context, session *auth.Session, i CreatedAt: current.CreatedAt, } if current.Repo != "" { - record.Repo = ¤t.Repo + record.Repo = current.Repo } client := NewPDSClient(session, s.HTTPClient) var swap *string @@ -305,11 +309,7 @@ func (s *IssueService) getIssueByParts(ctx context.Context, did, collection, rke if out.Cid != nil { cid = *out.Cid } - repoURI := "" - if record.Repo != nil { - repoURI = *record.Repo - } - return &Issue{Title: record.Title, Body: body, Repo: repoURI, State: "open", Author: did, CreatedAt: record.CreatedAt, URI: out.Uri, CID: cid}, nil + return &Issue{Title: record.Title, Body: body, Repo: record.Repo, State: "open", Author: did, CreatedAt: record.CreatedAt, URI: out.Uri, CID: cid}, nil } func assignIssueNumbers(issues []Issue) { diff --git a/internal/tangled/pulls.go b/internal/tangled/pulls.go index 7547aa1..8b4686d 100644 --- a/internal/tangled/pulls.go +++ b/internal/tangled/pulls.go @@ -69,12 +69,12 @@ func (s *PullService) ListPulls(ctx context.Context, repoURI string, status stri if limit <= 0 { limit = 50 } - backlinks, err := s.Constellation.GetBacklinks(ctx, repoURI, core.RepoPullNSID, ".target.repo", limit, "") + links, err := repoBacklinks(ctx, s.Constellation, s.HTTPClient, repoURI, core.RepoPullNSID, ".target.repo", limit, "") if err != nil { return nil, err } - pulls := make([]Pull, 0, len(backlinks.Records)) - for _, link := range backlinks.Records { + pulls := make([]Pull, 0, len(links)) + for _, link := range links { pull, err := s.getPullByParts(ctx, link.DID, link.Collection, link.RKey) if err != nil { continue @@ -104,13 +104,17 @@ func (s *PullService) CreatePull(ctx context.Context, session *auth.Session, opt if opts.HeadBranch == "" { return nil, fmt.Errorf("head branch is required") } - repoIdentifier := opts.Repo.RepoDID - if repoIdentifier == "" { - ownerDID, _, err := resolveOwner(ctx, opts.Repo.Owner) + repoDID := opts.Repo.RepoDID + if repoDID == "" { + var err error + repoDID, err = ResolveRepoDID(ctx, opts.RepoURI, s.HTTPClient) if err != nil { return nil, err } - repoIdentifier = ownerDID + "/" + opts.Repo.Name + } + repoIdentifier := opts.Repo.RepoDID + if repoIdentifier == "" { + repoIdentifier = repoDID } compare, err := NewKnotClient(opts.Repo.Knot, WithKnotHTTPClient(s.HTTPClient)).Compare(ctx, repoIdentifier, opts.BaseBranch, opts.HeadBranch) if err != nil { @@ -158,12 +162,11 @@ func (s *PullService) CreatePull(ctx context.Context, session *auth.Session, opt Body: optionalString(body), CreatedAt: now, Target: &core.RepoPull_Target{ - Repo: &opts.RepoURI, - RepoDid: optionalString(opts.Repo.RepoDID), - Branch: opts.BaseBranch, + Repo: repoDID, + Branch: opts.BaseBranch, }, Source: &core.RepoPull_Source{ - Repo: &opts.RepoURI, + Repo: &repoDID, Branch: opts.HeadBranch, }, Rounds: []*core.RepoPull_Round{{ @@ -176,7 +179,7 @@ func (s *PullService) CreatePull(ctx context.Context, session *auth.Session, opt if err != nil { return nil, err } - return &Pull{Title: title, Body: body, Status: "open", Author: session.DID, CreatedAt: now, URI: out.Uri, CID: out.Cid, Target: opts.BaseBranch, Source: opts.RepoURI, Branch: opts.HeadBranch}, nil + return &Pull{Title: title, Body: body, Status: "open", Author: session.DID, CreatedAt: now, URI: out.Uri, CID: out.Cid, Target: opts.BaseBranch, Source: repoDID, Branch: opts.HeadBranch}, nil } func (s *PullService) GetPull(ctx context.Context, pullURI string) (*Pull, error) { diff --git a/internal/tangled/repos.go b/internal/tangled/repos.go index f3c7241..2784db3 100644 --- a/internal/tangled/repos.go +++ b/internal/tangled/repos.go @@ -109,7 +109,7 @@ func (s *RepoService) CreateRepo(ctx context.Context, session *auth.Session, opt } record := &core.Repo{ LexiconTypeID: core.RepoNSID, - Name: opts.Name, + Name: &opts.Name, Knot: knot, Description: optionalString(opts.Description), CreatedAt: time.Now().UTC().Format(time.RFC3339), @@ -152,6 +152,10 @@ func (s *RepoService) CloneURL(repo Repo) (string, error) { } func repoFromRecord(owner, uri, cid string, record *core.Repo) Repo { + name := "" + if record.Name != nil { + name = *record.Name + } description := "" if record.Description != nil { description = *record.Description @@ -163,15 +167,15 @@ func repoFromRecord(owner, uri, cid string, record *core.Repo) Repo { cloneHost := cloneHostForKnot(record.Knot) return Repo{ Owner: owner, - Name: record.Name, + Name: name, Description: description, Knot: record.Knot, RepoDID: repoDID, CreatedAt: record.CreatedAt, URI: uri, CID: cid, - CloneSSH: fmt.Sprintf("git@%s:%s/%s", cloneHost, owner, record.Name), - CloneHTTPS: fmt.Sprintf("https://%s/%s/%s", cloneHost, owner, record.Name), + CloneSSH: fmt.Sprintf("git@%s:%s/%s", cloneHost, owner, name), + CloneHTTPS: fmt.Sprintf("https://%s/%s/%s", cloneHost, owner, name), } } diff --git a/internal/tangled/repos_test.go b/internal/tangled/repos_test.go index e62caef..a0ce4f4 100644 --- a/internal/tangled/repos_test.go +++ b/internal/tangled/repos_test.go @@ -11,8 +11,9 @@ import ( ) func TestRepoFromRecordBuildsCloneURLs(t *testing.T) { + name := "tang" repo := repoFromRecord("onev.cat", "at://did/sh.tangled.repo/r", "cid", &core.Repo{ - Name: "tang", + Name: &name, Knot: "knot.example.com", CreatedAt: "2026-05-02T00:00:00Z", }) @@ -25,8 +26,9 @@ func TestRepoFromRecordBuildsCloneURLs(t *testing.T) { } func TestRepoFromRecordUsesHostedCloneHostForDefaultHostedKnot(t *testing.T) { + name := "tang" repo := repoFromRecord("onev.cat", "at://did/sh.tangled.repo/r", "cid", &core.Repo{ - Name: "tang", + Name: &name, Knot: "knot1.tangled.sh", CreatedAt: "2026-05-02T00:00:00Z", }) @@ -75,10 +77,11 @@ func TestRepoCloneURLRejectsUnsupportedProtocol(t *testing.T) { } func TestRepoFromRecordCopiesOptionalFields(t *testing.T) { + name := "tang" description := "Command-line client" repoDID := "did:plc:repo" repo := repoFromRecord("onev.cat", "at://did/sh.tangled.repo/r", "cid", &core.Repo{ - Name: "tang", + Name: &name, Knot: "knot.example.com", Description: &description, RepoDid: &repoDID, diff --git a/internal/tangled/service_test.go b/internal/tangled/service_test.go index 369eadb..56929c3 100644 --- a/internal/tangled/service_test.go +++ b/internal/tangled/service_test.go @@ -57,6 +57,12 @@ func TestRepoServiceListAndGetRepoUseResolvedPDS(t *testing.T) { func TestIssueServiceCreateGetStateAndComments(t *testing.T) { const did = "did:plc:alice" records := map[string]fakeRecord{ + core.RepoNSID + "/r1": { + URI: "at://did:plc:alice/sh.tangled.repo/r1", + CID: "repo-cid", + Lexicon: core.RepoNSID, + RecordJSON: `{"$type":"sh.tangled.repo","name":"tang","knot":"knot1.tangled.sh","repoDid":"did:plc:repo","createdAt":"2026-05-02T00:00:00Z"}`, + }, core.RepoIssueNSID + "/i1": { URI: "at://did:plc:alice/sh.tangled.repo.issue/i1", CID: "issue-cid", @@ -82,7 +88,15 @@ func TestIssueServiceCreateGetStateAndComments(t *testing.T) { RecordJSON: `{"$type":"sh.tangled.repo.issue.comment","issue":"at://did:plc:alice/sh.tangled.repo.issue/i1","body":"first","createdAt":"2026-05-02T00:01:00Z"}`, }, } - pds := newFakePDSServer(t, records) + var createdIssue map[string]any + pds := newFakePDSServer(t, records, withCreateRecordHook(func(collection string, record json.RawMessage) { + if collection != core.RepoIssueNSID { + return + } + if err := json.Unmarshal(record, &createdIssue); err != nil { + t.Fatalf("Unmarshal created issue error = %v", err) + } + })) constellation := newFakeConstellationServer(t, map[string][]fakeLink{ core.RepoIssueStateNSID: { {DID: did, Collection: core.RepoIssueStateNSID, RKey: "s1"}, @@ -125,6 +139,9 @@ func TestIssueServiceCreateGetStateAndComments(t *testing.T) { if created.Title != "New" || created.Body != "New body" || created.State != "open" || created.URI == "" { t.Fatalf("created issue = %#v", created) } + if createdIssue["repo"] != "did:plc:repo" { + t.Fatalf("created issue repo = %#v", createdIssue) + } comment, err := service.AddComment(context.Background(), session, issue.URI, "hello") if err != nil { t.Fatalf("AddComment error = %v", err) @@ -195,6 +212,58 @@ func TestIssueServiceListAndUpdateIssue(t *testing.T) { } } +func TestIssueServiceListIssuesQueriesRepoDIDAndLegacyATURI(t *testing.T) { + const did = "did:plc:alice" + const repoURI = "at://did:plc:alice/sh.tangled.repo/r1" + const repoDID = "did:plc:repo" + records := map[string]fakeRecord{ + core.RepoNSID + "/r1": { + URI: repoURI, + CID: "repo-cid", + Lexicon: core.RepoNSID, + RecordJSON: `{"$type":"sh.tangled.repo","name":"tang","knot":"knot1.tangled.sh","repoDid":"` + repoDID + `","createdAt":"2026-05-02T00:00:00Z"}`, + }, + core.RepoIssueNSID + "/legacy": { + URI: "at://did:plc:alice/sh.tangled.repo.issue/legacy", + CID: "legacy-cid", + Lexicon: core.RepoIssueNSID, + RecordJSON: `{"$type":"sh.tangled.repo.issue","repo":"` + repoURI + `","title":"Legacy","createdAt":"2026-05-02T00:00:00Z"}`, + }, + core.RepoIssueNSID + "/canonical": { + URI: "at://did:plc:alice/sh.tangled.repo.issue/canonical", + CID: "canonical-cid", + Lexicon: core.RepoIssueNSID, + RecordJSON: `{"$type":"sh.tangled.repo.issue","repo":"` + repoDID + `","title":"Canonical","createdAt":"2026-05-02T00:01:00Z"}`, + }, + } + pds := newFakePDSServer(t, records) + var issueTargets []string + constellation := newFakeConstellationServer(t, map[string][]fakeLink{ + core.RepoIssueNSID: { + {DID: did, Collection: core.RepoIssueNSID, RKey: "canonical", Target: repoDID}, + {DID: did, Collection: core.RepoIssueNSID, RKey: "canonical", Target: repoURI}, + {DID: did, Collection: core.RepoIssueNSID, RKey: "legacy", Target: repoURI}, + }, + }, withConstellationQueryHook(func(collection, target, _ string) { + if collection == core.RepoIssueNSID { + issueTargets = append(issueTargets, target) + } + })) + stubResolvers(t, did, "onev.cat", pds.URL) + service := NewIssueService(&config.Config{Constellation: config.ConstellationConfig{URL: constellation.URL}}, pds.Client()) + + issues, err := service.ListIssues(context.Background(), repoURI, IssueListOptions{State: "all"}) + if err != nil { + t.Fatalf("ListIssues error = %v", err) + } + if len(issues) != 2 || issues[0].Title != "Legacy" || issues[1].Title != "Canonical" { + t.Fatalf("issues = %#v", issues) + } + if !containsString(issueTargets, repoDID) || !containsString(issueTargets, repoURI) { + t.Fatalf("issue targets = %#v", issueTargets) + } +} + func TestPullServiceGetStatusPatchAndMergeCheck(t *testing.T) { const did = "did:plc:alice" patch := "From abc\nSubject: Change README\n\nPatch body" @@ -304,6 +373,58 @@ func TestPullServiceListAndMutations(t *testing.T) { } } +func TestPullServiceListPullsQueriesRepoDIDAndLegacyATURI(t *testing.T) { + const did = "did:plc:alice" + const repoURI = "at://did:plc:alice/sh.tangled.repo/r1" + const repoDID = "did:plc:repo" + records := map[string]fakeRecord{ + core.RepoNSID + "/r1": { + URI: repoURI, + CID: "repo-cid", + Lexicon: core.RepoNSID, + RecordJSON: `{"$type":"sh.tangled.repo","name":"tang","knot":"knot1.tangled.sh","repoDid":"` + repoDID + `","createdAt":"2026-05-02T00:00:00Z"}`, + }, + core.RepoPullNSID + "/legacy": { + URI: "at://did:plc:alice/sh.tangled.repo.pull/legacy", + CID: "legacy-cid", + Lexicon: core.RepoPullNSID, + RecordJSON: `{"$type":"sh.tangled.repo.pull","title":"Legacy","createdAt":"2026-05-02T00:00:00Z","target":{"repo":"` + repoURI + `","branch":"main"},"source":{"repo":"` + repoURI + `","branch":"feature-a"},"rounds":[]}`, + }, + core.RepoPullNSID + "/canonical": { + URI: "at://did:plc:alice/sh.tangled.repo.pull/canonical", + CID: "canonical-cid", + Lexicon: core.RepoPullNSID, + RecordJSON: `{"$type":"sh.tangled.repo.pull","title":"Canonical","createdAt":"2026-05-02T00:01:00Z","target":{"repo":"` + repoDID + `","branch":"main"},"source":{"repo":"` + repoDID + `","branch":"feature-b"},"rounds":[]}`, + }, + } + pds := newFakePDSServer(t, records) + var pullTargets []string + constellation := newFakeConstellationServer(t, map[string][]fakeLink{ + core.RepoPullNSID: { + {DID: did, Collection: core.RepoPullNSID, RKey: "canonical", Target: repoDID}, + {DID: did, Collection: core.RepoPullNSID, RKey: "canonical", Target: repoURI}, + {DID: did, Collection: core.RepoPullNSID, RKey: "legacy", Target: repoURI}, + }, + }, withConstellationQueryHook(func(collection, target, _ string) { + if collection == core.RepoPullNSID { + pullTargets = append(pullTargets, target) + } + })) + stubResolvers(t, did, "onev.cat", pds.URL) + service := NewPullService(&config.Config{Constellation: config.ConstellationConfig{URL: constellation.URL}}, pds.Client()) + + pulls, err := service.ListPulls(context.Background(), repoURI, "all", 0) + if err != nil { + t.Fatalf("ListPulls error = %v", err) + } + if len(pulls) != 2 || pulls[0].Title != "Legacy" || pulls[1].Title != "Canonical" { + t.Fatalf("pulls = %#v", pulls) + } + if !containsString(pullTargets, repoDID) || !containsString(pullTargets, repoURI) { + t.Fatalf("pull targets = %#v", pullTargets) + } +} + func TestPDSClientSessionServiceAuthBlobAndDelete(t *testing.T) { const blobCID = "bafkreieqq463374bbcbeq7gpmet5rvrpeqow6t4rtjzrkhnlumdylagaqa" pds := newFakePDSServer(t, map[string]fakeRecord{}, withBlob(blobCID, []byte("blob-data"))) @@ -465,7 +586,15 @@ func TestRepoServiceCreateRepo(t *testing.T) { } func TestPullServiceCreatePullAndMergeCheck(t *testing.T) { - server := newCombinedXRPCServer(t) + var createdPull map[string]any + server := newCombinedXRPCServer(t, withCombinedPutRecordHook(func(collection, _ string, record json.RawMessage) { + if collection != core.RepoPullNSID { + return + } + if err := json.Unmarshal(record, &createdPull); err != nil { + t.Fatalf("Unmarshal created pull error = %v", err) + } + })) host := strings.TrimPrefix(server.URL, "https://") patch := "From abc\nSubject: Add tests\n\nPatch" blobCID := "bafkreieqq463374bbcbeq7gpmet5rvrpeqow6t4rtjzrkhnlumdylagaqa" @@ -494,6 +623,14 @@ func TestPullServiceCreatePullAndMergeCheck(t *testing.T) { if pull.Title != "Add tests" || pull.Branch != "feature" || pull.Target != "main" { t.Fatalf("pull = %#v", pull) } + target, ok := createdPull["target"].(map[string]any) + if !ok || target["repo"] != "did:plc:repo" { + t.Fatalf("created pull target = %#v", createdPull) + } + source, ok := createdPull["source"].(map[string]any) + if !ok || source["repo"] != "did:plc:repo" { + t.Fatalf("created pull source = %#v", createdPull) + } mergeService := NewPullService(&config.Config{Constellation: config.ConstellationConfig{URL: "http://127.0.0.1"}}, server.Client()) got, err := mergeService.MergeCheck(context.Background(), Repo{Name: "tang", Knot: host}, "did:plc:alice", Pull{URI: "at://did:plc:alice/sh.tangled.repo.pull/p1", Target: "main"}) @@ -505,8 +642,24 @@ func TestPullServiceCreatePullAndMergeCheck(t *testing.T) { } } -func newCombinedXRPCServer(t *testing.T) *httptest.Server { +type combinedXRPCOption func(*combinedXRPCState) + +type combinedXRPCState struct { + putRecordHook func(collection, rkey string, record json.RawMessage) +} + +func withCombinedPutRecordHook(hook func(collection, rkey string, record json.RawMessage)) combinedXRPCOption { + return func(s *combinedXRPCState) { + s.putRecordHook = hook + } +} + +func newCombinedXRPCServer(t *testing.T, opts ...combinedXRPCOption) *httptest.Server { t.Helper() + state := &combinedXRPCState{} + for _, opt := range opts { + opt(state) + } server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/xrpc/com.atproto.server.getServiceAuth": @@ -523,13 +676,17 @@ func newCombinedXRPCServer(t *testing.T) *httptest.Server { }) case "/xrpc/com.atproto.repo.putRecord": var input struct { - Collection string `json:"collection"` - RKey string `json:"rkey"` + Collection string `json:"collection"` + RKey string `json:"rkey"` + Record json.RawMessage `json:"record"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + if state.putRecordHook != nil { + state.putRecordHook(input.Collection, input.RKey, input.Record) + } writeJSON(t, w, map[string]string{ "uri": "at://did:plc:alice/" + input.Collection + "/" + input.RKey, "cid": "put-cid", @@ -559,13 +716,16 @@ type fakeLink struct { DID string Collection string RKey string + Target string } type fakePDSOption func(*fakePDSState) type fakePDSState struct { - records map[string]fakeRecord - blobs map[string][]byte + records map[string]fakeRecord + blobs map[string][]byte + createRecordHook func(collection string, record json.RawMessage) + putRecordHook func(collection, rkey string, record json.RawMessage) } func withBlob(cid string, data []byte) fakePDSOption { @@ -574,6 +734,18 @@ func withBlob(cid string, data []byte) fakePDSOption { } } +func withCreateRecordHook(hook func(collection string, record json.RawMessage)) fakePDSOption { + return func(s *fakePDSState) { + s.createRecordHook = hook + } +} + +func withPutRecordHook(hook func(collection, rkey string, record json.RawMessage)) fakePDSOption { + return func(s *fakePDSState) { + s.putRecordHook = hook + } +} + func newFakePDSServer(t *testing.T, records map[string]fakeRecord, opts ...fakePDSOption) *httptest.Server { t.Helper() state := &fakePDSState{records: records, blobs: map[string][]byte{}} @@ -633,6 +805,9 @@ func newFakePDSServer(t *testing.T, records map[string]fakeRecord, opts ...fakeP http.Error(w, err.Error(), http.StatusBadRequest) return } + if state.createRecordHook != nil { + state.createRecordHook(input.Collection, input.Record) + } rkey := "created" writeJSON(t, w, map[string]string{ "uri": "at://did:plc:alice/" + input.Collection + "/" + rkey, @@ -640,13 +815,17 @@ func newFakePDSServer(t *testing.T, records map[string]fakeRecord, opts ...fakeP }) case "/xrpc/com.atproto.repo.putRecord": var input struct { - Collection string `json:"collection"` - RKey string `json:"rkey"` + Collection string `json:"collection"` + RKey string `json:"rkey"` + Record json.RawMessage `json:"record"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + if state.putRecordHook != nil { + state.putRecordHook(input.Collection, input.RKey, input.Record) + } writeJSON(t, w, map[string]string{ "uri": "at://did:plc:alice/" + input.Collection + "/" + input.RKey, "cid": "put-cid", @@ -693,14 +872,42 @@ func recordJSON(t *testing.T, record fakeRecord) []byte { return data } -func newFakeConstellationServer(t *testing.T, links map[string][]fakeLink) *httptest.Server { +type fakeConstellationOption func(*fakeConstellationState) + +type fakeConstellationState struct { + queryHook func(collection, target, path string) +} + +func withConstellationQueryHook(hook func(collection, target, path string)) fakeConstellationOption { + return func(s *fakeConstellationState) { + s.queryHook = hook + } +} + +func newFakeConstellationServer(t *testing.T, links map[string][]fakeLink, opts ...fakeConstellationOption) *httptest.Server { t.Helper() + state := &fakeConstellationState{} + for _, opt := range opts { + opt(state) + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/links" { http.NotFound(w, r) return } collection := r.URL.Query().Get("collection") + target := r.URL.Query().Get("target") + if state.queryHook != nil { + state.queryHook(collection, target, r.URL.Query().Get("path")) + } + sourceLinks := links[collection] + filterByTarget := false + for _, link := range sourceLinks { + if link.Target != "" { + filterByTarget = true + break + } + } wire := struct { Total int `json:"total"` LinkingRecords []struct { @@ -708,14 +915,18 @@ func newFakeConstellationServer(t *testing.T, links map[string][]fakeLink) *http Collection string `json:"collection"` RKey string `json:"rkey"` } `json:"linking_records"` - }{Total: len(links[collection])} - for _, link := range links[collection] { + }{} + for _, link := range sourceLinks { + if filterByTarget && link.Target != target { + continue + } wire.LinkingRecords = append(wire.LinkingRecords, struct { DID string `json:"did"` Collection string `json:"collection"` RKey string `json:"rkey"` }{DID: link.DID, Collection: link.Collection, RKey: link.RKey}) } + wire.Total = len(wire.LinkingRecords) writeJSON(t, w, wire) })) t.Cleanup(server.Close) @@ -751,6 +962,15 @@ func gzipBytes(t *testing.T, input string) []byte { return out.Bytes() } +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + func writeJSON(t *testing.T, w http.ResponseWriter, value any) { t.Helper() w.Header().Set("Content-Type", "application/json")