From e07d17bcaa6c62b6ad13c53be20328bf4d7f3905 Mon Sep 17 00:00:00 2001 From: Lewis Date: Fri, 08 May 2026 09:09:59 +0000 Subject: [PATCH] appview,knotmirror: tolerate 1.13 knots during 1.14 rollout Lewis: May this revision serve well! --- appview/compat113/compat.go | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/compat113/compat_test.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/compat113/version.go | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/compat113/version_test.go | 35 +++++++++++++++++++++++++++++++++++ appview/ingester_repo.go | 5 ++++- appview/pulls/create.go | 9 +++------ appview/pulls/resubmit.go | 13 ++++--------- appview/repo/repo.go | 10 +++++++--- appview/repoverify/verify.go | 4 ++++ knotmirror/knotstream/slurper.go | 15 ++++++++++----- 10 file(s) changed, 327 insertion(s)(+), 24 deletion(s)(-) diff --git a/appview/compat113/compat.go b/appview/compat113/compat.go new file mode 100644 --- /dev/null +++ b/appview/compat113/compat.go @@ -0,0 +1,87 @@ +package compat113 + +import ( + "encoding/json" + "io" + + lexutil "github.com/bluesky-social/indigo/lex/util" + "tangled.org/core/api/tangled" +) + +func Collaborator(r *tangled.RepoCollaborator) *lexutil.LexiconTypeDecoder { + return &lexutil.LexiconTypeDecoder{Val: &collaboratorWrapper{inner: r}} +} + +func Pull(r *tangled.RepoPull) *lexutil.LexiconTypeDecoder { + return &lexutil.LexiconTypeDecoder{Val: &pullWrapper{inner: r}} +} + +type collaboratorWrapper struct { + LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.collaborator"` + inner *tangled.RepoCollaborator +} + +func (c *collaboratorWrapper) MarshalJSON() ([]byte, error) { + c.inner.LexiconTypeID = "sh.tangled.repo.collaborator" + return marshalWithRepoDidShadow(c.inner, false) +} + +func (c *collaboratorWrapper) MarshalCBOR(w io.Writer) error { + return c.inner.MarshalCBOR(w) +} + +type pullWrapper struct { + LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.pull"` + inner *tangled.RepoPull +} + +func (c *pullWrapper) MarshalJSON() ([]byte, error) { + c.inner.LexiconTypeID = "sh.tangled.repo.pull" + return marshalWithRepoDidShadow(c.inner, true) +} + +func (c *pullWrapper) MarshalCBOR(w io.Writer) error { + return c.inner.MarshalCBOR(w) +} + +func marshalWithRepoDidShadow(inner any, nestedTarget bool) ([]byte, error) { + raw, err := json.Marshal(inner) + if err != nil { + return nil, err + } + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + return raw, nil + } + if nestedTarget { + injectIntoNested(top, "target") + injectIntoNested(top, "source") + } else { + addRepoDidShadow(top) + } + return json.Marshal(top) +} + +func injectIntoNested(parent map[string]json.RawMessage, key string) { + raw, ok := parent[key] + if !ok { + return + } + var nested map[string]json.RawMessage + if err := json.Unmarshal(raw, &nested); err != nil { + return + } + addRepoDidShadow(nested) + if reb, err := json.Marshal(nested); err == nil { + parent[key] = reb + } +} + +func addRepoDidShadow(m map[string]json.RawMessage) { + if _, has := m["repoDid"]; has { + return + } + if v, ok := m["repo"]; ok { + m["repoDid"] = v + } +} diff --git a/appview/compat113/compat_test.go b/appview/compat113/compat_test.go new file mode 100644 --- /dev/null +++ b/appview/compat113/compat_test.go @@ -0,0 +1,113 @@ +package compat113 + +import ( + "encoding/json" + "testing" + + "tangled.org/core/api/tangled" +) + +func ptr[T any](v T) *T { return &v } + +func TestCollaboratorShadowsRepoDid(t *testing.T) { + rec := &tangled.RepoCollaborator{ + CreatedAt: "2026-05-08T00:00:00Z", + Repo: "did:plc:abalone", + Subject: "did:plc:limpet", + } + + out, err := json.Marshal(Collaborator(rec)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got["$type"] != "sh.tangled.repo.collaborator" { + t.Errorf("$type = %v, want sh.tangled.repo.collaborator", got["$type"]) + } + if got["repo"] != "did:plc:abalone" { + t.Errorf("repo = %v, want did:plc:abalone", got["repo"]) + } + if got["repoDid"] != "did:plc:abalone" { + t.Errorf("repoDid shadow missing or wrong: got %v", got["repoDid"]) + } +} + +func TestPullShadowsTargetRepoDid(t *testing.T) { + rec := &tangled.RepoPull{ + CreatedAt: "2026-05-08T00:00:00Z", + Title: "rename whelk handler", + Target: &tangled.RepoPull_Target{ + Branch: "main", + Repo: "did:plc:scallop", + }, + Source: &tangled.RepoPull_Source{ + Branch: "feature-1", + }, + } + + out, err := json.Marshal(Pull(rec)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + target, ok := got["target"].(map[string]any) + if !ok { + t.Fatalf("target missing or wrong type: %v", got["target"]) + } + if target["repo"] != "did:plc:scallop" { + t.Errorf("target.repo = %v", target["repo"]) + } + if target["repoDid"] != "did:plc:scallop" { + t.Errorf("target.repoDid shadow missing: %v", target["repoDid"]) + } + + if _, has := got["repoDid"]; has { + t.Errorf("top-level repoDid should not be set on pull: %v", got["repoDid"]) + } +} + +func TestPullShadowsForkSourceRepoDid(t *testing.T) { + rec := &tangled.RepoPull{ + CreatedAt: "2026-05-08T00:00:00Z", + Title: "fork-based PR", + Target: &tangled.RepoPull_Target{ + Branch: "main", + Repo: "did:plc:scallop", + }, + Source: &tangled.RepoPull_Source{ + Branch: "feature-2", + Repo: ptr("did:plc:periwinkle"), + }, + } + + out, err := json.Marshal(Pull(rec)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + source, ok := got["source"].(map[string]any) + if !ok { + t.Fatalf("source missing: %v", got["source"]) + } + if source["repo"] != "did:plc:periwinkle" { + t.Errorf("source.repo = %v", source["repo"]) + } + if source["repoDid"] != "did:plc:periwinkle" { + t.Errorf("source.repoDid shadow missing: %v", source["repoDid"]) + } +} diff --git a/appview/compat113/version.go b/appview/compat113/version.go new file mode 100644 --- /dev/null +++ b/appview/compat113/version.go @@ -0,0 +1,60 @@ +package compat113 + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "tangled.org/core/api/tangled" +) + +const versionProbeTimeout = 5 * time.Second + +func KnotSupports114(ctx context.Context, host string, dev bool) bool { + scheme := "https" + if dev { + scheme = "http" + } + client := &indigoxrpc.Client{ + Host: fmt.Sprintf("%s://%s", scheme, host), + Client: &http.Client{Timeout: versionProbeTimeout}, + } + + ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout) + defer cancel() + + resp, err := tangled.KnotVersion(ctx, client) + if err != nil || resp == nil { + return true + } + return atLeast114(resp.Version) +} + +func atLeast114(v string) bool { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + if strings.HasPrefix(v, "(devel)") { + return true + } + if v == "" { + return false + } + parts := strings.SplitN(v, ".", 3) + if len(parts) < 2 { + return false + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return false + } + minorRaw := strings.SplitN(parts[1], "-", 2)[0] + minor, err := strconv.Atoi(minorRaw) + if err != nil { + return false + } + return major > 1 || (major == 1 && minor >= 14) +} diff --git a/appview/compat113/version_test.go b/appview/compat113/version_test.go new file mode 100644 --- /dev/null +++ b/appview/compat113/version_test.go @@ -0,0 +1,35 @@ +package compat113 + +import "testing" + +func TestAtLeast114(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"v1.14.0", true}, + {"v1.14.0-alpha", true}, + {"v1.14.5", true}, + {"v1.13.0", false}, + {"v1.13.0-alpha", false}, + {"v1.0.0", false}, + {"v2.0.0", true}, + {"1.14.0", true}, + {"1.13.99", false}, + {"(devel)", true}, + {"", false}, + {"garbagio-furioso", false}, + {"v1", false}, + {"vX.Y.Z", false}, + {"unknown", false}, + {"unknown-abc1234", false}, + {"unknown-abc1234-modified", false}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := atLeast114(c.in); got != c.want { + t.Errorf("atLeast114(%q) = %v, want %v", c.in, got, c.want) + } + }) + } +} diff --git a/appview/ingester_repo.go b/appview/ingester_repo.go --- a/appview/ingester_repo.go +++ b/appview/ingester_repo.go @@ -371,7 +371,10 @@ result, err := i.Verifier(ctx, rd) if err != nil { return false, fmt.Errorf("verify repo ownership: %w", err) } - if result.OwnerDid.String() != eventDid { + if result.OwnerDid == "" { + l.Warn("knot lacks RepoDescribeRepo, skipping owner check; upgrade knot to 1.14+", + "repoDid", repoDid, "knot", result.KnotURL.String()) + } else if result.OwnerDid.String() != eventDid { l.Warn("rejecting repo event: owner mismatch", "repoDid", repoDid, "claimedOwner", eventDid, diff --git a/appview/pulls/create.go b/appview/pulls/create.go --- a/appview/pulls/create.go +++ b/appview/pulls/create.go @@ -11,6 +11,7 @@ "strings" "time" "tangled.org/core/api/tangled" + "tangled.org/core/appview/compat113" "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" @@ -301,9 +302,7 @@ _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoPullNSID, Repo: userDid.String(), Rkey: rkey, - Record: &lexutil.LexiconTypeDecoder{ - Val: &record, - }, + Record: compat113.Pull(&record), }) if err != nil { l.Error("failed to create pull request", "err", err) @@ -403,9 +402,7 @@ writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ Collection: tangled.RepoPullNSID, Rkey: &p.Rkey, - Value: &lexutil.LexiconTypeDecoder{ - Val: &record, - }, + Value: compat113.Pull(&record), }, }) } diff --git a/appview/pulls/resubmit.go b/appview/pulls/resubmit.go --- a/appview/pulls/resubmit.go +++ b/appview/pulls/resubmit.go @@ -7,6 +7,7 @@ "net/http" "time" "tangled.org/core/api/tangled" + "tangled.org/core/appview/compat113" "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" @@ -330,9 +331,7 @@ Collection: tangled.RepoPullNSID, Repo: userDid.String(), Rkey: pull.Rkey, SwapRecord: ex.Cid, - Record: &lexutil.LexiconTypeDecoder{ - Val: &record, - }, + Record: compat113.Pull(&record), }) if err != nil { l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey) @@ -521,9 +520,7 @@ writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ Collection: tangled.RepoPullNSID, Rkey: &p.Rkey, - Value: &lexutil.LexiconTypeDecoder{ - Val: &record, - }, + Value: compat113.Pull(&record), }, }) } @@ -581,9 +578,7 @@ writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ Collection: tangled.RepoPullNSID, Rkey: op.Rkey, - Value: &lexutil.LexiconTypeDecoder{ - Val: &record, - }, + Value: compat113.Pull(&record), }, }) } diff --git a/appview/repo/repo.go b/appview/repo/repo.go --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -15,6 +15,7 @@ "tangled.org/core/appview/cloudflare" "tangled.org/core/api/tangled" + "tangled.org/core/appview/compat113" "tangled.org/core/appview/config" "tangled.org/core/appview/db" "tangled.org/core/appview/models" @@ -759,9 +760,7 @@ resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoCollaboratorNSID, Repo: currentUser.Did, Rkey: rkey, - Record: &lexutil.LexiconTypeDecoder{ - Val: repoCollaboratorRecord(f, collaboratorIdent.DID.String(), createdAt), - }, + Record: compat113.Collaborator(repoCollaboratorRecord(f, collaboratorIdent.DID.String(), createdAt)), }) // invalid record if err != nil { @@ -847,6 +846,11 @@ l = l.With("did", user.Did, "rkey", f.Rkey, "oldName", f.Name) if f.RepoDid == "" { rp.pages.Notice(w, noticeId, "This repository's knot has not completed the DID migration; rename is unavailable.") + return + } + + if !compat113.KnotSupports114(r.Context(), f.Knot, rp.config.Core.Dev) { + rp.pages.Notice(w, noticeId, "This repository's knot is below v1.14 and does not yet support renames. Ask the knot operator to upgrade.") return } diff --git a/appview/repoverify/verify.go b/appview/repoverify/verify.go --- a/appview/repoverify/verify.go +++ b/appview/repoverify/verify.go @@ -2,6 +2,7 @@ package repoverify import ( "context" + "errors" "fmt" "net" "net/http" @@ -109,6 +110,9 @@ client := &indigoxrpc.Client{Host: knot.String(), Client: httpClient} out, err := tangled.RepoDescribeRepo(ctx, client, repoDid.String()) if xrpcErr := xrpcclient.HandleXrpcErr(err); xrpcErr != nil { + if errors.Is(xrpcErr, xrpcclient.ErrXrpcUnsupported) { + return Result{RepoDid: repoDid, KnotURL: knot}, nil + } return Result{}, fmt.Errorf("describeRepo on %s: %w", knot, xrpcErr) } diff --git a/knotmirror/knotstream/slurper.go b/knotmirror/knotstream/slurper.go --- a/knotmirror/knotstream/slurper.go +++ b/knotmirror/knotstream/slurper.go @@ -262,8 +262,9 @@ } } type legacyGitRefUpdate struct { - OwnerDid *string `json:"ownerDid,omitempty"` - RepoDid *string `json:"repo,omitempty"` + OwnerDid *string `json:"ownerDid,omitempty"` + Repo *string `json:"repo,omitempty"` + LegacyRepoDid *string `json:"repoDid,omitempty"` } type LegacyGitEvent struct { @@ -288,10 +289,14 @@ // lookupRepoForRefUpdate resolves the local repo row for an incoming refUpdate // via the stable RepoDid join. Returns (nil, "", nil) when the event has no // repoDid (unjoinable) and (nil, key, nil) on a clean miss. func (s *KnotSlurper) lookupRepoForRefUpdate(ctx context.Context, evt *LegacyGitEvent) (*models.Repo, string, error) { - if evt.Event.RepoDid == nil || *evt.Event.RepoDid == "" { + raw := evt.Event.Repo + if raw == nil || *raw == "" { + raw = evt.Event.LegacyRepoDid + } + if raw == nil || *raw == "" { return nil, "", nil } - repoDid := syntax.DID(*evt.Event.RepoDid) + repoDid := syntax.DID(*raw) curr, err := db.GetRepoByRepoDid(ctx, s.db, repoDid) return curr, repoDid.String(), err } @@ -308,7 +313,7 @@ } if curr == nil { if lookupKey == "" { l.Warn("skipping gitRefUpdate: event has no fields to join on", - "repo_did", evt.Event.RepoDid) + "repo", evt.Event.Repo, "legacy_repo_did", evt.Event.LegacyRepoDid) } else { // if repo doesn't exist in DB, just ignore the event. That repo is unknown. // Hopefully crawler/tap will sync it later. -- tangled.sh