appview/labels: add "subscribe all" button for default labels #597

merged
opened by oppi.li targeting master from push-lxxtrqtnnoxy

quickly subscribe to all default labels.

Signed-off-by: oppiliappan me@oppi.li

Changed files
+304 -42
appview
db
labels
models
pages
templates
repo
settings
repo
state
+1 -2
appview/db/db.go
··· 527 -- label to subscribe to 528 label_at text not null, 529 530 - unique (repo_at, label_at), 531 - foreign key (label_at) references label_definitions (at_uri) 532 ); 533 534 create table if not exists migrations (
··· 527 -- label to subscribe to 528 label_at text not null, 529 530 + unique (repo_at, label_at) 531 ); 532 533 create table if not exists migrations (
+16 -3
appview/db/repos.go
··· 345 return &repo, nil 346 } 347 348 - func AddRepo(e Execer, repo *models.Repo) error { 349 - _, err := e.Exec( 350 `insert into repos 351 (did, name, knot, rkey, at_uri, description, source) 352 values (?, ?, ?, ?, ?, ?, ?)`, 353 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 354 ) 355 - return err 356 } 357 358 func RemoveRepo(e Execer, did, name string) error {
··· 345 return &repo, nil 346 } 347 348 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 349 + _, err := tx.Exec( 350 `insert into repos 351 (did, name, knot, rkey, at_uri, description, source) 352 values (?, ?, ?, ?, ?, ?, ?)`, 353 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 354 ) 355 + if err != nil { 356 + return fmt.Errorf("failed to insert repo: %w", err) 357 + } 358 + 359 + for _, dl := range repo.Labels { 360 + if err := SubscribeLabel(tx, &models.RepoLabel{ 361 + RepoAt: repo.RepoAt(), 362 + LabelAt: syntax.ATURI(dl), 363 + }); err != nil { 364 + return fmt.Errorf("failed to subscribe to label: %w", err) 365 + } 366 + } 367 + 368 + return nil 369 } 370 371 func RemoveRepo(e Execer, did, name string) error {
+80
appview/ingester.go
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 9 "time" 10 ··· 80 err = i.ingestIssueComment(e) 81 case tangled.LabelDefinitionNSID: 82 err = i.ingestLabelDefinition(e) 83 } 84 l = i.Logger.With("nsid", e.Commit.Collection) 85 } ··· 953 954 return nil 955 }
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 + "maps" 9 + "slices" 10 11 "time" 12 ··· 82 err = i.ingestIssueComment(e) 83 case tangled.LabelDefinitionNSID: 84 err = i.ingestLabelDefinition(e) 85 + case tangled.LabelOpNSID: 86 + err = i.ingestLabelOp(e) 87 } 88 l = i.Logger.With("nsid", e.Commit.Collection) 89 } ··· 957 958 return nil 959 } 960 + 961 + func (i *Ingester) ingestLabelOp(e *jmodels.Event) error { 962 + did := e.Did 963 + rkey := e.Commit.RKey 964 + 965 + var err error 966 + 967 + l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 968 + l.Info("ingesting record") 969 + 970 + ddb, ok := i.Db.Execer.(*db.DB) 971 + if !ok { 972 + return fmt.Errorf("failed to index label op, invalid db cast") 973 + } 974 + 975 + switch e.Commit.Operation { 976 + case jmodels.CommitOperationCreate: 977 + raw := json.RawMessage(e.Commit.Record) 978 + record := tangled.LabelOp{} 979 + err = json.Unmarshal(raw, &record) 980 + if err != nil { 981 + return fmt.Errorf("invalid record: %w", err) 982 + } 983 + 984 + subject := syntax.ATURI(record.Subject) 985 + collection := subject.Collection() 986 + 987 + var repo *models.Repo 988 + switch collection { 989 + case tangled.RepoIssueNSID: 990 + i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 991 + if err != nil || len(i) != 1 { 992 + return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 993 + } 994 + repo = i[0].Repo 995 + default: 996 + return fmt.Errorf("unsupport label subject: %s", collection) 997 + } 998 + 999 + actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1000 + if err != nil { 1001 + return fmt.Errorf("failed to build label application ctx: %w", err) 1002 + } 1003 + 1004 + ops := models.LabelOpsFromRecord(did, rkey, record) 1005 + 1006 + for _, o := range ops { 1007 + def, ok := actx.Defs[o.OperandKey] 1008 + if !ok { 1009 + return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 + } 1011 + if err := i.Validator.ValidateLabelOp(def, &o); err != nil { 1012 + return fmt.Errorf("failed to validate labelop: %w", err) 1013 + } 1014 + } 1015 + 1016 + tx, err := ddb.Begin() 1017 + if err != nil { 1018 + return err 1019 + } 1020 + defer tx.Rollback() 1021 + 1022 + for _, o := range ops { 1023 + _, err = db.AddLabelOp(tx, &o) 1024 + if err != nil { 1025 + return fmt.Errorf("failed to add labelop: %w", err) 1026 + } 1027 + } 1028 + 1029 + if err = tx.Commit(); err != nil { 1030 + return err 1031 + } 1032 + } 1033 + 1034 + return nil 1035 + }
-3
appview/labels/labels.go
··· 104 return 105 } 106 107 - l.logger.Info("actx", "labels", labelAts) 108 - l.logger.Info("actx", "defs", actx.Defs) 109 - 110 // calculate the start state by applying already known labels 111 existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 112 if err != nil {
··· 104 return 105 } 106 107 // calculate the start state by applying already known labels 108 existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 109 if err != nil {
+67
appview/models/label.go
··· 1 package models 2 3 import ( 4 "crypto/sha1" 5 "encoding/hex" 6 "errors" 7 "fmt" 8 "slices" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/consts" 14 ) 15 16 type ConcreteType string ··· 471 472 return defs 473 }
··· 1 package models 2 3 import ( 4 + "context" 5 "crypto/sha1" 6 "encoding/hex" 7 + "encoding/json" 8 "errors" 9 "fmt" 10 "slices" 11 "time" 12 13 + "github.com/bluesky-social/indigo/api/atproto" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/consts" 18 + "tangled.org/core/idresolver" 19 ) 20 21 type ConcreteType string ··· 476 477 return defs 478 } 479 + 480 + func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 481 + resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 482 + if err != nil { 483 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 484 + } 485 + pdsEndpoint := resolved.PDSEndpoint() 486 + if pdsEndpoint == "" { 487 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 488 + } 489 + client := &xrpc.Client{ 490 + Host: pdsEndpoint, 491 + } 492 + 493 + var labelDefs []LabelDefinition 494 + 495 + for _, dl := range DefaultLabelDefs() { 496 + atUri := syntax.ATURI(dl) 497 + parsedUri, err := syntax.ParseATURI(string(atUri)) 498 + if err != nil { 499 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 500 + } 501 + record, err := atproto.RepoGetRecord( 502 + context.Background(), 503 + client, 504 + "", 505 + parsedUri.Collection().String(), 506 + parsedUri.Authority().String(), 507 + parsedUri.RecordKey().String(), 508 + ) 509 + if err != nil { 510 + return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 511 + } 512 + 513 + if record != nil { 514 + bytes, err := record.Value.MarshalJSON() 515 + if err != nil { 516 + return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 517 + } 518 + 519 + raw := json.RawMessage(bytes) 520 + labelRecord := tangled.LabelDefinition{} 521 + err = json.Unmarshal(raw, &labelRecord) 522 + if err != nil { 523 + return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 524 + } 525 + 526 + labelDef, err := LabelDefinitionFromRecord( 527 + parsedUri.Authority().String(), 528 + parsedUri.RecordKey().String(), 529 + labelRecord, 530 + ) 531 + if err != nil { 532 + return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 533 + } 534 + 535 + labelDefs = append(labelDefs, *labelDef) 536 + } 537 + } 538 + 539 + return labelDefs, nil 540 + }
+10 -9
appview/pages/pages.go
··· 834 } 835 836 type RepoGeneralSettingsParams struct { 837 - LoggedInUser *oauth.User 838 - RepoInfo repoinfo.RepoInfo 839 - Labels []models.LabelDefinition 840 - DefaultLabels []models.LabelDefinition 841 - SubscribedLabels map[string]struct{} 842 - Active string 843 - Tabs []map[string]any 844 - Tab string 845 - Branches []types.Branch 846 } 847 848 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
··· 834 } 835 836 type RepoGeneralSettingsParams struct { 837 + LoggedInUser *oauth.User 838 + RepoInfo repoinfo.RepoInfo 839 + Labels []models.LabelDefinition 840 + DefaultLabels []models.LabelDefinition 841 + SubscribedLabels map[string]struct{} 842 + ShouldSubscribeAll bool 843 + Active string 844 + Tabs []map[string]any 845 + Tab string 846 + Branches []types.Branch 847 } 848 849 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
+36 -6
appview/pages/templates/repo/settings/general.html
··· 46 47 {{ define "defaultLabelSettings" }} 48 <div class="flex flex-col gap-2"> 49 - <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 50 - <p class="text-gray-500 dark:text-gray-400"> 51 - Manage your issues and pulls by creating labels to categorize them. Only 52 - repository owners may configure labels. You may choose to subscribe to 53 - default labels, or create entirely custom labels. 54 - </p> 55 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 56 {{ range .DefaultLabels }} 57 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
··· 46 47 {{ define "defaultLabelSettings" }} 48 <div class="flex flex-col gap-2"> 49 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 50 + <div class="col-span-1 md:col-span-2"> 51 + <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 52 + <p class="text-gray-500 dark:text-gray-400"> 53 + Manage your issues and pulls by creating labels to categorize them. Only 54 + repository owners may configure labels. You may choose to subscribe to 55 + default labels, or create entirely custom labels. 56 + <p> 57 + </div> 58 + <form class="col-span-1 md:col-span-1 md:justify-self-end"> 59 + {{ $title := "Unubscribe from all labels" }} 60 + {{ $icon := "x" }} 61 + {{ $text := "unsubscribe all" }} 62 + {{ $action := "unsubscribe" }} 63 + {{ if $.ShouldSubscribeAll }} 64 + {{ $title = "Subscribe to all labels" }} 65 + {{ $icon = "check-check" }} 66 + {{ $text = "subscribe all" }} 67 + {{ $action = "subscribe" }} 68 + {{ end }} 69 + {{ range .DefaultLabels }} 70 + <input type="hidden" name="label" value="{{ .AtUri.String }}"> 71 + {{ end }} 72 + <button 73 + type="submit" 74 + title="{{$title}}" 75 + class="btn flex items-center gap-2 group" 76 + hx-swap="none" 77 + hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}" 78 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 79 + {{ i $icon "size-4" }} 80 + {{ $text }} 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </button> 83 + </form> 84 + </div> 85 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 86 {{ range .DefaultLabels }} 87 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+61 -19
appview/repo/repo.go
··· 1248 return 1249 } 1250 1251 errorId := "default-label-operation" 1252 fail := func(msg string, err error) { 1253 l.Error(msg, "err", err) 1254 rp.pages.Notice(w, errorId, msg) 1255 } 1256 1257 - labelAt := r.FormValue("label") 1258 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1259 if err != nil { 1260 fail("Failed to subscribe to label.", err) 1261 return 1262 } 1263 1264 newRepo := f.Repo 1265 - newRepo.Labels = append(newRepo.Labels, labelAt) 1266 repoRecord := newRepo.AsRecord() 1267 1268 client, err := rp.oauth.AuthorizedClient(r) ··· 1286 }, 1287 }) 1288 1289 - err = db.SubscribeLabel(rp.db, &models.RepoLabel{ 1290 - RepoAt: f.RepoAt(), 1291 - LabelAt: syntax.ATURI(labelAt), 1292 - }) 1293 if err != nil { 1294 fail("Failed to subscribe to label.", err) 1295 return 1296 } 1297 1298 // everything succeeded 1299 rp.pages.HxRefresh(w) ··· 1311 return 1312 } 1313 1314 errorId := "default-label-operation" 1315 fail := func(msg string, err error) { 1316 l.Error(msg, "err", err) 1317 rp.pages.Notice(w, errorId, msg) 1318 } 1319 1320 - labelAt := r.FormValue("label") 1321 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1322 if err != nil { 1323 fail("Failed to unsubscribe to label.", err) 1324 return ··· 1328 newRepo := f.Repo 1329 var updated []string 1330 for _, l := range newRepo.Labels { 1331 - if l != labelAt { 1332 updated = append(updated, l) 1333 } 1334 } ··· 1359 err = db.UnsubscribeLabel( 1360 rp.db, 1361 db.FilterEq("repo_at", f.RepoAt()), 1362 - db.FilterEq("label_at", labelAt), 1363 ) 1364 if err != nil { 1365 fail("Failed to unsubscribe label.", err) ··· 1927 subscribedLabels[l] = struct{}{} 1928 } 1929 1930 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1931 - LoggedInUser: user, 1932 - RepoInfo: f.RepoInfo(user), 1933 - Branches: result.Branches, 1934 - Labels: labels, 1935 - DefaultLabels: defaultLabels, 1936 - SubscribedLabels: subscribedLabels, 1937 - Tabs: settingsTabs, 1938 - Tab: "general", 1939 }) 1940 } 1941 ··· 2150 Source: sourceAt, 2151 Description: existingRepo.Description, 2152 Created: time.Now(), 2153 } 2154 record := repo.AsRecord() 2155
··· 1248 return 1249 } 1250 1251 + if err := r.ParseForm(); err != nil { 1252 + l.Error("invalid form", "err", err) 1253 + return 1254 + } 1255 + 1256 errorId := "default-label-operation" 1257 fail := func(msg string, err error) { 1258 l.Error(msg, "err", err) 1259 rp.pages.Notice(w, errorId, msg) 1260 } 1261 1262 + labelAts := r.Form["label"] 1263 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1264 if err != nil { 1265 fail("Failed to subscribe to label.", err) 1266 return 1267 } 1268 1269 newRepo := f.Repo 1270 + newRepo.Labels = append(newRepo.Labels, labelAts...) 1271 + 1272 + // dedup 1273 + slices.Sort(newRepo.Labels) 1274 + newRepo.Labels = slices.Compact(newRepo.Labels) 1275 + 1276 repoRecord := newRepo.AsRecord() 1277 1278 client, err := rp.oauth.AuthorizedClient(r) ··· 1296 }, 1297 }) 1298 1299 + tx, err := rp.db.Begin() 1300 if err != nil { 1301 fail("Failed to subscribe to label.", err) 1302 return 1303 } 1304 + defer tx.Rollback() 1305 + 1306 + for _, l := range labelAts { 1307 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1308 + RepoAt: f.RepoAt(), 1309 + LabelAt: syntax.ATURI(l), 1310 + }) 1311 + if err != nil { 1312 + fail("Failed to subscribe to label.", err) 1313 + return 1314 + } 1315 + } 1316 + 1317 + if err := tx.Commit(); err != nil { 1318 + fail("Failed to subscribe to label.", err) 1319 + return 1320 + } 1321 1322 // everything succeeded 1323 rp.pages.HxRefresh(w) ··· 1335 return 1336 } 1337 1338 + if err := r.ParseForm(); err != nil { 1339 + l.Error("invalid form", "err", err) 1340 + return 1341 + } 1342 + 1343 errorId := "default-label-operation" 1344 fail := func(msg string, err error) { 1345 l.Error(msg, "err", err) 1346 rp.pages.Notice(w, errorId, msg) 1347 } 1348 1349 + labelAts := r.Form["label"] 1350 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1351 if err != nil { 1352 fail("Failed to unsubscribe to label.", err) 1353 return ··· 1357 newRepo := f.Repo 1358 var updated []string 1359 for _, l := range newRepo.Labels { 1360 + if !slices.Contains(labelAts, l) { 1361 updated = append(updated, l) 1362 } 1363 } ··· 1388 err = db.UnsubscribeLabel( 1389 rp.db, 1390 db.FilterEq("repo_at", f.RepoAt()), 1391 + db.FilterIn("label_at", labelAts), 1392 ) 1393 if err != nil { 1394 fail("Failed to unsubscribe label.", err) ··· 1956 subscribedLabels[l] = struct{}{} 1957 } 1958 1959 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1960 + // if all default labels are subbed, show the "unsubscribe all" button 1961 + shouldSubscribeAll := false 1962 + for _, dl := range defaultLabels { 1963 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1964 + // one of the default labels is not subscribed to 1965 + shouldSubscribeAll = true 1966 + break 1967 + } 1968 + } 1969 + 1970 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1971 + LoggedInUser: user, 1972 + RepoInfo: f.RepoInfo(user), 1973 + Branches: result.Branches, 1974 + Labels: labels, 1975 + DefaultLabels: defaultLabels, 1976 + SubscribedLabels: subscribedLabels, 1977 + ShouldSubscribeAll: shouldSubscribeAll, 1978 + Tabs: settingsTabs, 1979 + Tab: "general", 1980 }) 1981 } 1982 ··· 2191 Source: sourceAt, 2192 Description: existingRepo.Description, 2193 Created: time.Now(), 2194 + Labels: models.DefaultLabelDefs(), 2195 } 2196 record := repo.AsRecord() 2197
+33
appview/state/state.go
··· 103 tangled.RepoIssueNSID, 104 tangled.RepoIssueCommentNSID, 105 tangled.LabelDefinitionNSID, 106 }, 107 nil, 108 slog.Default(), ··· 117 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 118 } 119 120 ingester := appview.Ingester{ 121 Db: wrapper, 122 Enforcer: enforcer, ··· 440 Rkey: rkey, 441 Description: description, 442 Created: time.Now(), 443 } 444 record := repo.AsRecord() 445 ··· 580 }) 581 return err 582 }
··· 103 tangled.RepoIssueNSID, 104 tangled.RepoIssueCommentNSID, 105 tangled.LabelDefinitionNSID, 106 + tangled.LabelOpNSID, 107 }, 108 nil, 109 slog.Default(), ··· 118 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 119 } 120 121 + if err := db.BackfillDefaultDefs(d, res); err != nil { 122 + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 123 + } 124 + 125 ingester := appview.Ingester{ 126 Db: wrapper, 127 Enforcer: enforcer, ··· 445 Rkey: rkey, 446 Description: description, 447 Created: time.Now(), 448 + Labels: models.DefaultLabelDefs(), 449 } 450 record := repo.AsRecord() 451 ··· 586 }) 587 return err 588 } 589 + 590 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 591 + defaults := models.DefaultLabelDefs() 592 + defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 593 + if err != nil { 594 + return err 595 + } 596 + // already present 597 + if len(defaultLabels) == len(defaults) { 598 + return nil 599 + } 600 + 601 + labelDefs, err := models.FetchDefaultDefs(r) 602 + if err != nil { 603 + return err 604 + } 605 + 606 + // Insert each label definition to the database 607 + for _, labelDef := range labelDefs { 608 + _, err = db.AddLabelDefinition(e, &labelDef) 609 + if err != nil { 610 + return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 611 + } 612 + } 613 + 614 + return nil 615 + }