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 527 -- label to subscribe to 528 528 label_at text not null, 529 529 530 - unique (repo_at, label_at), 531 - foreign key (label_at) references label_definitions (at_uri) 530 + unique (repo_at, label_at) 532 531 ); 533 532 534 533 create table if not exists migrations (
+16 -3
appview/db/repos.go
··· 345 345 return &repo, nil 346 346 } 347 347 348 - func AddRepo(e Execer, repo *models.Repo) error { 349 - _, err := e.Exec( 348 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 349 + _, err := tx.Exec( 350 350 `insert into repos 351 351 (did, name, knot, rkey, at_uri, description, source) 352 352 values (?, ?, ?, ?, ?, ?, ?)`, 353 353 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 354 354 ) 355 - return err 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 356 369 } 357 370 358 371 func RemoveRepo(e Execer, did, name string) error {
+80
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "maps" 9 + "slices" 8 10 9 11 "time" 10 12 ··· 80 82 err = i.ingestIssueComment(e) 81 83 case tangled.LabelDefinitionNSID: 82 84 err = i.ingestLabelDefinition(e) 85 + case tangled.LabelOpNSID: 86 + err = i.ingestLabelOp(e) 83 87 } 84 88 l = i.Logger.With("nsid", e.Commit.Collection) 85 89 } ··· 953 957 954 958 return nil 955 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 104 return 105 105 } 106 106 107 - l.logger.Info("actx", "labels", labelAts) 108 - l.logger.Info("actx", "defs", actx.Defs) 109 - 110 107 // calculate the start state by applying already known labels 111 108 existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 112 109 if err != nil {
+67
appview/models/label.go
··· 1 1 package models 2 2 3 3 import ( 4 + "context" 4 5 "crypto/sha1" 5 6 "encoding/hex" 7 + "encoding/json" 6 8 "errors" 7 9 "fmt" 8 10 "slices" 9 11 "time" 10 12 13 + "github.com/bluesky-social/indigo/api/atproto" 11 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 12 16 "tangled.org/core/api/tangled" 13 17 "tangled.org/core/consts" 18 + "tangled.org/core/idresolver" 14 19 ) 15 20 16 21 type ConcreteType string ··· 471 476 472 477 return defs 473 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 834 } 835 835 836 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 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 846 847 } 847 848 848 849 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
+36 -6
appview/pages/templates/repo/settings/general.html
··· 46 46 47 47 {{ define "defaultLabelSettings" }} 48 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> 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> 55 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"> 56 86 {{ range .DefaultLabels }} 57 87 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+61 -19
appview/repo/repo.go
··· 1248 1248 return 1249 1249 } 1250 1250 1251 + if err := r.ParseForm(); err != nil { 1252 + l.Error("invalid form", "err", err) 1253 + return 1254 + } 1255 + 1251 1256 errorId := "default-label-operation" 1252 1257 fail := func(msg string, err error) { 1253 1258 l.Error(msg, "err", err) 1254 1259 rp.pages.Notice(w, errorId, msg) 1255 1260 } 1256 1261 1257 - labelAt := r.FormValue("label") 1258 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1262 + labelAts := r.Form["label"] 1263 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1259 1264 if err != nil { 1260 1265 fail("Failed to subscribe to label.", err) 1261 1266 return 1262 1267 } 1263 1268 1264 1269 newRepo := f.Repo 1265 - newRepo.Labels = append(newRepo.Labels, labelAt) 1270 + newRepo.Labels = append(newRepo.Labels, labelAts...) 1271 + 1272 + // dedup 1273 + slices.Sort(newRepo.Labels) 1274 + newRepo.Labels = slices.Compact(newRepo.Labels) 1275 + 1266 1276 repoRecord := newRepo.AsRecord() 1267 1277 1268 1278 client, err := rp.oauth.AuthorizedClient(r) ··· 1286 1296 }, 1287 1297 }) 1288 1298 1289 - err = db.SubscribeLabel(rp.db, &models.RepoLabel{ 1290 - RepoAt: f.RepoAt(), 1291 - LabelAt: syntax.ATURI(labelAt), 1292 - }) 1299 + tx, err := rp.db.Begin() 1293 1300 if err != nil { 1294 1301 fail("Failed to subscribe to label.", err) 1295 1302 return 1296 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 + } 1297 1321 1298 1322 // everything succeeded 1299 1323 rp.pages.HxRefresh(w) ··· 1311 1335 return 1312 1336 } 1313 1337 1338 + if err := r.ParseForm(); err != nil { 1339 + l.Error("invalid form", "err", err) 1340 + return 1341 + } 1342 + 1314 1343 errorId := "default-label-operation" 1315 1344 fail := func(msg string, err error) { 1316 1345 l.Error(msg, "err", err) 1317 1346 rp.pages.Notice(w, errorId, msg) 1318 1347 } 1319 1348 1320 - labelAt := r.FormValue("label") 1321 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1349 + labelAts := r.Form["label"] 1350 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1322 1351 if err != nil { 1323 1352 fail("Failed to unsubscribe to label.", err) 1324 1353 return ··· 1328 1357 newRepo := f.Repo 1329 1358 var updated []string 1330 1359 for _, l := range newRepo.Labels { 1331 - if l != labelAt { 1360 + if !slices.Contains(labelAts, l) { 1332 1361 updated = append(updated, l) 1333 1362 } 1334 1363 } ··· 1359 1388 err = db.UnsubscribeLabel( 1360 1389 rp.db, 1361 1390 db.FilterEq("repo_at", f.RepoAt()), 1362 - db.FilterEq("label_at", labelAt), 1391 + db.FilterIn("label_at", labelAts), 1363 1392 ) 1364 1393 if err != nil { 1365 1394 fail("Failed to unsubscribe label.", err) ··· 1927 1956 subscribedLabels[l] = struct{}{} 1928 1957 } 1929 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 + 1930 1970 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", 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", 1939 1980 }) 1940 1981 } 1941 1982 ··· 2150 2191 Source: sourceAt, 2151 2192 Description: existingRepo.Description, 2152 2193 Created: time.Now(), 2194 + Labels: models.DefaultLabelDefs(), 2153 2195 } 2154 2196 record := repo.AsRecord() 2155 2197
+33
appview/state/state.go
··· 103 103 tangled.RepoIssueNSID, 104 104 tangled.RepoIssueCommentNSID, 105 105 tangled.LabelDefinitionNSID, 106 + tangled.LabelOpNSID, 106 107 }, 107 108 nil, 108 109 slog.Default(), ··· 117 118 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 118 119 } 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 + 120 125 ingester := appview.Ingester{ 121 126 Db: wrapper, 122 127 Enforcer: enforcer, ··· 440 445 Rkey: rkey, 441 446 Description: description, 442 447 Created: time.Now(), 448 + Labels: models.DefaultLabelDefs(), 443 449 } 444 450 record := repo.AsRecord() 445 451 ··· 580 586 }) 581 587 return err 582 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 + }