Monorepo for Tangled tangled.org
9

Configure Feed

Select the types of activity you want to include in your feed.

appview: delete knot repos when its registration is removed

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (May 26, 2026, 2:33 PM +0300) commit c3b18ecd parent e55db596 change-id ttoqupvz
+230 -11
+2 -2
.dockerignore
··· 11 11 .wrangler/ 12 12 localinfra/certs/root.key 13 13 14 - appview/pages/static/* 15 - !appview/pages/static/topbar-search.js 14 + appview/pages/static/tw.css 15 + appview/pages/static/x 16 16 17 17 sites/target 18 18 sites/.wrangler
+28
appview/db/db.go
··· 2099 2099 return err 2100 2100 }) 2101 2101 2102 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 2103 + orm.RunMigration(conn, logger, "cascade-notification-entity-fks", func(tx *sql.Tx) error { 2104 + _, err := tx.Exec(` 2105 + CREATE TABLE notifications_new ( 2106 + id INTEGER PRIMARY KEY AUTOINCREMENT, 2107 + recipient_did TEXT NOT NULL, 2108 + actor_did TEXT NOT NULL, 2109 + type TEXT NOT NULL, 2110 + entity_type TEXT NOT NULL, 2111 + entity_id TEXT NOT NULL, 2112 + read INTEGER NOT NULL DEFAULT 0, 2113 + created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 2114 + repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE, 2115 + issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE, 2116 + pull_id INTEGER REFERENCES pulls(id) ON DELETE CASCADE 2117 + ); 2118 + INSERT INTO notifications_new (id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id) 2119 + SELECT id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 2120 + FROM notifications; 2121 + DROP TABLE notifications; 2122 + ALTER TABLE notifications_new RENAME TO notifications; 2123 + CREATE INDEX idx_notifications_recipient_created ON notifications(recipient_did, created DESC); 2124 + CREATE INDEX idx_notifications_recipient_read ON notifications(recipient_did, read); 2125 + `) 2126 + return err 2127 + }) 2128 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 2129 + 2102 2130 return &DB{ 2103 2131 db, 2104 2132 logger,
+5
appview/db/repos.go
··· 561 561 return err 562 562 } 563 563 564 + func RemoveReposByKnot(e Execer, knot string) error { 565 + _, err := e.Exec(`delete from repos where knot = ?`, knot) 566 + return err 567 + } 568 + 564 569 func GetRepoSource(e Execer, repoDid string) (string, error) { 565 570 var nullableSource sql.NullString 566 571 err := e.QueryRow(`select source from repos where repo_did = ?`, repoDid).Scan(&nullableSource)
+112
appview/db/repos_test.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "path/filepath" 6 + "testing" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/appview/models" 10 + ) 11 + 12 + func TestRemoveReposByKnotCascadesEntities(t *testing.T) { 13 + d := newTestDB(t) 14 + 15 + knot := "kelp.example" 16 + repo := seedRepo(t, d, "did:plc:akshay", knot, "anemone", "anemone", "did:plc:anemone") 17 + 18 + starNotif := &models.Notification{ 19 + RecipientDid: "did:plc:akshay", 20 + ActorDid: "did:plc:boltless", 21 + Type: models.NotificationTypeRepoStarred, 22 + EntityType: "repo", 23 + EntityId: repo.RepoAt().String(), 24 + RepoId: &repo.Id, 25 + } 26 + if err := CreateNotification(d, starNotif); err != nil { 27 + t.Fatalf("CreateNotification repo: %v", err) 28 + } 29 + 30 + tx, err := d.Begin() 31 + if err != nil { 32 + t.Fatalf("Begin: %v", err) 33 + } 34 + issue := &models.Issue{ 35 + Did: "did:plc:akshay", 36 + Rkey: "issue1", 37 + RepoDid: syntax.DID(repo.RepoDid), 38 + Title: "title", 39 + Body: "body", 40 + Open: true, 41 + } 42 + if err := PutIssue(tx, issue); err != nil { 43 + t.Fatalf("PutIssue: %v", err) 44 + } 45 + if err := tx.Commit(); err != nil { 46 + t.Fatalf("Commit: %v", err) 47 + } 48 + 49 + issueNotif := &models.Notification{ 50 + RecipientDid: "did:plc:akshay", 51 + ActorDid: "did:plc:boltless", 52 + Type: models.NotificationTypeIssueCommented, 53 + EntityType: "issue", 54 + EntityId: issue.AtUri().String(), 55 + IssueId: &issue.Id, 56 + } 57 + if err := CreateNotification(d, issueNotif); err != nil { 58 + t.Fatalf("CreateNotification issue: %v", err) 59 + } 60 + 61 + if err := RemoveReposByKnot(d, knot); err != nil { 62 + t.Fatalf("RemoveReposByKnot: %v", err) 63 + } 64 + 65 + if got := countRows(t, d, "select count(*) from repos where knot = ?", knot); got != 0 { 66 + t.Errorf("repos remaining: got %d, want 0", got) 67 + } 68 + if got := countRows(t, d, "select count(*) from issues where repo_did = ?", repo.RepoDid); got != 0 { 69 + t.Errorf("issues remaining: got %d, want 0", got) 70 + } 71 + if got := countRows(t, d, "select count(*) from notifications"); got != 0 { 72 + t.Errorf("notifications remaining: got %d, want 0", got) 73 + } 74 + } 75 + 76 + func TestMakeReopenPreservesOrphanData(t *testing.T) { 77 + path := filepath.Join(t.TempDir(), "reopen.db") 78 + 79 + d, err := Make(context.Background(), path) 80 + if err != nil { 81 + t.Fatalf("first Make: %v", err) 82 + } 83 + 84 + repo := seedRepo(t, d, "did:plc:akshay", "ghost.example", "anemone", "anemone", "did:plc:anemone") 85 + notif := &models.Notification{ 86 + RecipientDid: "did:plc:akshay", 87 + ActorDid: "did:plc:boltless", 88 + Type: models.NotificationTypeRepoStarred, 89 + EntityType: "repo", 90 + EntityId: repo.RepoAt().String(), 91 + RepoId: &repo.Id, 92 + } 93 + if err := CreateNotification(d, notif); err != nil { 94 + t.Fatalf("CreateNotification: %v", err) 95 + } 96 + if err := d.Close(); err != nil { 97 + t.Fatalf("Close: %v", err) 98 + } 99 + 100 + d2, err := Make(context.Background(), path) 101 + if err != nil { 102 + t.Fatalf("second Make: %v", err) 103 + } 104 + t.Cleanup(func() { d2.Close() }) 105 + 106 + if got := countRows(t, d2, "select count(*) from repos where knot = ?", "ghost.example"); got != 1 { 107 + t.Errorf("orphan repo lost across reopen: got %d, want 1", got) 108 + } 109 + if got := countRows(t, d2, "select count(*) from notifications"); got != 1 { 110 + t.Errorf("notification lost across reopen: got %d, want 1", got) 111 + } 112 + }
+5
appview/ingester.go
··· 1190 1190 return err 1191 1191 } 1192 1192 1193 + err = db.RemoveReposByKnot(tx, domain) 1194 + if err != nil { 1195 + return err 1196 + } 1197 + 1193 1198 if registration.Registered != nil { 1194 1199 err = i.Enforcer.RemoveKnot(domain) 1195 1200 if err != nil {
+33 -2
appview/knots/knots.go
··· 70 70 return 71 71 } 72 72 73 + knots := make([]pages.KnotListingParams, 0, len(registrations)) 74 + for i := range registrations { 75 + registration := &registrations[i] 76 + count, err := db.CountRepos(k.Db, orm.FilterEq("knot", registration.Domain)) 77 + if err != nil { 78 + k.Logger.Error("failed to count knot repos", "err", err, "domain", registration.Domain) 79 + w.WriteHeader(http.StatusInternalServerError) 80 + return 81 + } 82 + knots = append(knots, pages.KnotListingParams{ 83 + Registration: registration, 84 + RepoCount: int(count), 85 + }) 86 + } 87 + 73 88 k.Pages.Knots(w, pages.KnotsParams{ 74 - LoggedInUser: user, 75 - Registrations: registrations, 89 + LoggedInUser: user, 90 + Knots: knots, 76 91 }) 77 92 } 78 93 ··· 134 149 Members: members, 135 150 Repos: repoMap, 136 151 IsOwner: true, 152 + RepoCount: len(repos), 137 153 }) 138 154 } 139 155 ··· 269 285 ) 270 286 if err != nil { 271 287 l.Error("failed to delete registration", "err", err) 288 + fail() 289 + return 290 + } 291 + 292 + err = db.RemoveReposByKnot(tx, domain) 293 + if err != nil { 294 + l.Error("failed to delete repos", "err", err) 272 295 fail() 273 296 return 274 297 } ··· 451 474 } 452 475 updatedRegistration := registrations[0] 453 476 477 + count, err := db.CountRepos(k.Db, orm.FilterEq("knot", domain)) 478 + if err != nil { 479 + l.Error("failed to count knot repos", "err", err) 480 + fail() 481 + return 482 + } 483 + 454 484 w.Header().Set("HX-Reswap", "outerHTML") 455 485 k.Pages.KnotListing(w, pages.KnotListingParams{ 456 486 Registration: &updatedRegistration, 487 + RepoCount: int(count), 457 488 }) 458 489 } 459 490
+5 -3
appview/pages/pages.go
··· 541 541 } 542 542 543 543 type KnotsParams struct { 544 - LoggedInUser *oauth.MultiAccountUser 545 - Registrations []models.Registration 546 - Tab string 544 + LoggedInUser *oauth.MultiAccountUser 545 + Knots []KnotListingParams 546 + Tab string 547 547 } 548 548 549 549 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 557 557 Members []string 558 558 Repos map[string][]models.Repo 559 559 IsOwner bool 560 + RepoCount int 560 561 Tab string 561 562 } 562 563 ··· 566 567 567 568 type KnotListingParams struct { 568 569 *models.Registration 570 + RepoCount int 569 571 } 570 572 571 573 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
+2 -2
appview/pages/templates/knots/dashboard.html
··· 43 43 {{ end }} 44 44 45 45 {{ if $isOwner }} 46 - {{ block "deleteButton" .Registration }} {{ end }} 46 + {{ block "deleteButton" (dict "Domain" .Registration.Domain "RepoCount" .RepoCount) }} {{ end }} 47 47 {{ end }} 48 48 </div> 49 49 </div> ··· 97 97 title="Delete knot" 98 98 hx-delete="/settings/knots/{{ .Domain }}" 99 99 hx-swap="outerHTML" 100 - hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 100 + hx-confirm="{{ template "knots/fragments/deleteConfirm" . }}" 101 101 hx-headers='{"shouldRedirect": "true"}' 102 102 > 103 103 {{ i "trash-2" "w-5 h-5" }}
+9 -1
appview/pages/templates/knots/fragments/knotListing.html
··· 59 59 hx-delete="/settings/knots/{{ .Domain }}" 60 60 hx-swap="outerHTML" 61 61 hx-target="#knot-{{.Id}}" 62 - hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 62 + hx-confirm="{{ template "knots/fragments/deleteConfirm" . }}" 63 63 > 64 64 {{ i "trash-2" "w-5 h-5" }} 65 65 <span class="hidden md:inline">delete</span> ··· 81 81 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 82 </button> 83 83 {{ end }} 84 + 85 + {{ define "knots/fragments/deleteConfirm" -}} 86 + {{ if .RepoCount -}} 87 + Unregistering '{{ .Domain }}' will remove {{ plural .RepoCount "repository" "" }} from Tangled. The git data will stay on the knot, but these repos will disappear from Tangled. Are you sure? 88 + {{- else -}} 89 + Are you sure you want to delete the knot '{{ .Domain }}'? 90 + {{- end -}} 91 + {{ end }}
+1 -1
appview/pages/templates/knots/index.html
··· 50 50 <section class="rounded w-full flex flex-col gap-2"> 51 51 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 52 52 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 53 - {{ range $registration := .Registrations }} 53 + {{ range .Knots }} 54 54 {{ template "knots/fragments/knotListing" . }} 55 55 {{ else }} 56 56 <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
+28
rbac/rbac_test.go
··· 362 362 assert.Empty(t, knots) 363 363 } 364 364 365 + func TestRemoveKnotRemovesRepoPolicies(t *testing.T) { 366 + e := setup(t) 367 + 368 + knot := "kelp.example" 369 + owner := "did:plc:akshay" 370 + collaborator := "did:plc:boltless" 371 + repo := "did:plc:akshay/anemone" 372 + 373 + assert.NoError(t, e.AddKnot(knot)) 374 + assert.NoError(t, e.AddKnotOwner(knot, owner)) 375 + assert.NoError(t, e.AddRepo(owner, knot, repo)) 376 + assert.NoError(t, e.AddCollaborator(collaborator, knot, repo)) 377 + 378 + isOwner, err := e.IsKnotOwner(owner, knot) 379 + assert.NoError(t, err) 380 + assert.True(t, isOwner) 381 + 382 + err = e.RemoveKnot(knot) 383 + assert.NoError(t, err) 384 + 385 + isOwner, err = e.IsKnotOwner(owner, knot) 386 + assert.NoError(t, err) 387 + assert.False(t, isOwner) 388 + 389 + assert.Empty(t, e.GetPermissionsInRepo(owner, knot, repo)) 390 + assert.Empty(t, e.GetPermissionsInRepo(collaborator, knot, repo)) 391 + } 392 + 365 393 func TestRemoveSpindleOwner(t *testing.T) { 366 394 e := setup(t) 367 395