From e420b9f0272d842233f99b08fb0540a52a86ea1a Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Wed, 24 Sep 2025 16:54:43 +0100 Subject: [PATCH] appview/labels: add "subscribe all" button for default labels Change-Id: lxxtrqtnnoxyolnpmupqyrnomlusvowq quickly subscribe to all default labels. Signed-off-by: oppiliappan --- appview/db/db.go | 3 +- appview/db/repos.go | 19 ++++- appview/ingester.go | 80 +++++++++++++++++++ appview/labels/labels.go | 3 - appview/models/label.go | 67 ++++++++++++++++ appview/pages/pages.go | 19 ++--- .../templates/repo/settings/general.html | 42 ++++++++-- appview/repo/repo.go | 80 ++++++++++++++----- appview/state/state.go | 33 ++++++++ 9 files changed, 304 insertions(+), 42 deletions(-) diff --git a/appview/db/db.go b/appview/db/db.go index 58fa50f0..6f6d0e41 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -527,8 +527,7 @@ func Make(dbPath string) (*DB, error) { -- label to subscribe to label_at text not null, - unique (repo_at, label_at), - foreign key (label_at) references label_definitions (at_uri) + unique (repo_at, label_at) ); create table if not exists migrations ( diff --git a/appview/db/repos.go b/appview/db/repos.go index b8a25284..48a94c74 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -345,14 +345,27 @@ func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { return &repo, nil } -func AddRepo(e Execer, repo *models.Repo) error { - _, err := e.Exec( +func AddRepo(tx *sql.Tx, repo *models.Repo) error { + _, err := tx.Exec( `insert into repos (did, name, knot, rkey, at_uri, description, source) values (?, ?, ?, ?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, ) - return err + if err != nil { + return fmt.Errorf("failed to insert repo: %w", err) + } + + for _, dl := range repo.Labels { + if err := SubscribeLabel(tx, &models.RepoLabel{ + RepoAt: repo.RepoAt(), + LabelAt: syntax.ATURI(dl), + }); err != nil { + return fmt.Errorf("failed to subscribe to label: %w", err) + } + } + + return nil } func RemoveRepo(e Execer, did, name string) error { diff --git a/appview/ingester.go b/appview/ingester.go index 64d51a34..dbde661c 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "log/slog" + "maps" + "slices" "time" @@ -80,6 +82,8 @@ func (i *Ingester) Ingest() processFunc { err = i.ingestIssueComment(e) case tangled.LabelDefinitionNSID: err = i.ingestLabelDefinition(e) + case tangled.LabelOpNSID: + err = i.ingestLabelOp(e) } l = i.Logger.With("nsid", e.Commit.Collection) } @@ -953,3 +957,79 @@ func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { return nil } + +func (i *Ingester) ingestLabelOp(e *jmodels.Event) error { + did := e.Did + rkey := e.Commit.RKey + + var err error + + l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) + l.Info("ingesting record") + + ddb, ok := i.Db.Execer.(*db.DB) + if !ok { + return fmt.Errorf("failed to index label op, invalid db cast") + } + + switch e.Commit.Operation { + case jmodels.CommitOperationCreate: + raw := json.RawMessage(e.Commit.Record) + record := tangled.LabelOp{} + err = json.Unmarshal(raw, &record) + if err != nil { + return fmt.Errorf("invalid record: %w", err) + } + + subject := syntax.ATURI(record.Subject) + collection := subject.Collection() + + var repo *models.Repo + switch collection { + case tangled.RepoIssueNSID: + i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) + if err != nil || len(i) != 1 { + return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) + } + repo = i[0].Repo + default: + return fmt.Errorf("unsupport label subject: %s", collection) + } + + actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) + if err != nil { + return fmt.Errorf("failed to build label application ctx: %w", err) + } + + ops := models.LabelOpsFromRecord(did, rkey, record) + + for _, o := range ops { + def, ok := actx.Defs[o.OperandKey] + if !ok { + return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) + } + if err := i.Validator.ValidateLabelOp(def, &o); err != nil { + return fmt.Errorf("failed to validate labelop: %w", err) + } + } + + tx, err := ddb.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, o := range ops { + _, err = db.AddLabelOp(tx, &o) + if err != nil { + return fmt.Errorf("failed to add labelop: %w", err) + } + } + + if err = tx.Commit(); err != nil { + return err + } + } + + return nil +} diff --git a/appview/labels/labels.go b/appview/labels/labels.go index 309ba1ce..ecd304b4 100644 --- a/appview/labels/labels.go +++ b/appview/labels/labels.go @@ -104,9 +104,6 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { return } - l.logger.Info("actx", "labels", labelAts) - l.logger.Info("actx", "defs", actx.Defs) - // calculate the start state by applying already known labels existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) if err != nil { diff --git a/appview/models/label.go b/appview/models/label.go index 60c99aaa..7c985696 100644 --- a/appview/models/label.go +++ b/appview/models/label.go @@ -1,16 +1,21 @@ package models import ( + "context" "crypto/sha1" "encoding/hex" + "encoding/json" "errors" "fmt" "slices" "time" + "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/consts" + "tangled.org/core/idresolver" ) type ConcreteType string @@ -471,3 +476,65 @@ func DefaultLabelDefs() []string { return defs } + +func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { + resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) + if err != nil { + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) + } + pdsEndpoint := resolved.PDSEndpoint() + if pdsEndpoint == "" { + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) + } + client := &xrpc.Client{ + Host: pdsEndpoint, + } + + var labelDefs []LabelDefinition + + for _, dl := range DefaultLabelDefs() { + atUri := syntax.ATURI(dl) + parsedUri, err := syntax.ParseATURI(string(atUri)) + if err != nil { + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) + } + record, err := atproto.RepoGetRecord( + context.Background(), + client, + "", + parsedUri.Collection().String(), + parsedUri.Authority().String(), + parsedUri.RecordKey().String(), + ) + if err != nil { + return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) + } + + if record != nil { + bytes, err := record.Value.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) + } + + raw := json.RawMessage(bytes) + labelRecord := tangled.LabelDefinition{} + err = json.Unmarshal(raw, &labelRecord) + if err != nil { + return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) + } + + labelDef, err := LabelDefinitionFromRecord( + parsedUri.Authority().String(), + parsedUri.RecordKey().String(), + labelRecord, + ) + if err != nil { + return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) + } + + labelDefs = append(labelDefs, *labelDef) + } + } + + return labelDefs, nil +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index df37834b..80eee26e 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -834,15 +834,16 @@ func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { } type RepoGeneralSettingsParams struct { - LoggedInUser *oauth.User - RepoInfo repoinfo.RepoInfo - Labels []models.LabelDefinition - DefaultLabels []models.LabelDefinition - SubscribedLabels map[string]struct{} - Active string - Tabs []map[string]any - Tab string - Branches []types.Branch + LoggedInUser *oauth.User + RepoInfo repoinfo.RepoInfo + Labels []models.LabelDefinition + DefaultLabels []models.LabelDefinition + SubscribedLabels map[string]struct{} + ShouldSubscribeAll bool + Active string + Tabs []map[string]any + Tab string + Branches []types.Branch } func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { diff --git a/appview/pages/templates/repo/settings/general.html b/appview/pages/templates/repo/settings/general.html index 2bae37a0..9afae837 100644 --- a/appview/pages/templates/repo/settings/general.html +++ b/appview/pages/templates/repo/settings/general.html @@ -46,12 +46,42 @@ {{ define "defaultLabelSettings" }}
-

Default Labels

-

- Manage your issues and pulls by creating labels to categorize them. Only - repository owners may configure labels. You may choose to subscribe to - default labels, or create entirely custom labels. -

+
+
+

Default Labels

+

+ Manage your issues and pulls by creating labels to categorize them. Only + repository owners may configure labels. You may choose to subscribe to + default labels, or create entirely custom labels. +

+

+
+ {{ $title := "Unubscribe from all labels" }} + {{ $icon := "x" }} + {{ $text := "unsubscribe all" }} + {{ $action := "unsubscribe" }} + {{ if $.ShouldSubscribeAll }} + {{ $title = "Subscribe to all labels" }} + {{ $icon = "check-check" }} + {{ $text = "subscribe all" }} + {{ $action = "subscribe" }} + {{ end }} + {{ range .DefaultLabels }} + + {{ end }} + +
+
{{ range .DefaultLabels }}
diff --git a/appview/repo/repo.go b/appview/repo/repo.go index 6390707b..4c1e556e 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -1248,21 +1248,31 @@ func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { return } + if err := r.ParseForm(); err != nil { + l.Error("invalid form", "err", err) + return + } + errorId := "default-label-operation" fail := func(msg string, err error) { l.Error(msg, "err", err) rp.pages.Notice(w, errorId, msg) } - labelAt := r.FormValue("label") - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) + labelAts := r.Form["label"] + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) if err != nil { fail("Failed to subscribe to label.", err) return } newRepo := f.Repo - newRepo.Labels = append(newRepo.Labels, labelAt) + newRepo.Labels = append(newRepo.Labels, labelAts...) + + // dedup + slices.Sort(newRepo.Labels) + newRepo.Labels = slices.Compact(newRepo.Labels) + repoRecord := newRepo.AsRecord() client, err := rp.oauth.AuthorizedClient(r) @@ -1286,14 +1296,28 @@ func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { }, }) - err = db.SubscribeLabel(rp.db, &models.RepoLabel{ - RepoAt: f.RepoAt(), - LabelAt: syntax.ATURI(labelAt), - }) + tx, err := rp.db.Begin() if err != nil { fail("Failed to subscribe to label.", err) return } + defer tx.Rollback() + + for _, l := range labelAts { + err = db.SubscribeLabel(tx, &models.RepoLabel{ + RepoAt: f.RepoAt(), + LabelAt: syntax.ATURI(l), + }) + if err != nil { + fail("Failed to subscribe to label.", err) + return + } + } + + if err := tx.Commit(); err != nil { + fail("Failed to subscribe to label.", err) + return + } // everything succeeded rp.pages.HxRefresh(w) @@ -1311,14 +1335,19 @@ func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { return } + if err := r.ParseForm(); err != nil { + l.Error("invalid form", "err", err) + return + } + errorId := "default-label-operation" fail := func(msg string, err error) { l.Error(msg, "err", err) rp.pages.Notice(w, errorId, msg) } - labelAt := r.FormValue("label") - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) + labelAts := r.Form["label"] + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) if err != nil { fail("Failed to unsubscribe to label.", err) return @@ -1328,7 +1357,7 @@ func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { newRepo := f.Repo var updated []string for _, l := range newRepo.Labels { - if l != labelAt { + if !slices.Contains(labelAts, l) { updated = append(updated, l) } } @@ -1359,7 +1388,7 @@ func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { err = db.UnsubscribeLabel( rp.db, db.FilterEq("repo_at", f.RepoAt()), - db.FilterEq("label_at", labelAt), + db.FilterIn("label_at", labelAts), ) if err != nil { fail("Failed to unsubscribe label.", err) @@ -1927,15 +1956,27 @@ func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { subscribedLabels[l] = struct{}{} } + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, + // if all default labels are subbed, show the "unsubscribe all" button + shouldSubscribeAll := false + for _, dl := range defaultLabels { + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { + // one of the default labels is not subscribed to + shouldSubscribeAll = true + break + } + } + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - Branches: result.Branches, - Labels: labels, - DefaultLabels: defaultLabels, - SubscribedLabels: subscribedLabels, - Tabs: settingsTabs, - Tab: "general", + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Branches: result.Branches, + Labels: labels, + DefaultLabels: defaultLabels, + SubscribedLabels: subscribedLabels, + ShouldSubscribeAll: shouldSubscribeAll, + Tabs: settingsTabs, + Tab: "general", }) } @@ -2150,6 +2191,7 @@ func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { Source: sourceAt, Description: existingRepo.Description, Created: time.Now(), + Labels: models.DefaultLabelDefs(), } record := repo.AsRecord() diff --git a/appview/state/state.go b/appview/state/state.go index 364e1a2e..52dbc967 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -103,6 +103,7 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { tangled.RepoIssueNSID, tangled.RepoIssueCommentNSID, tangled.LabelDefinitionNSID, + tangled.LabelOpNSID, }, nil, slog.Default(), @@ -117,6 +118,10 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { return nil, fmt.Errorf("failed to create jetstream client: %w", err) } + if err := db.BackfillDefaultDefs(d, res); err != nil { + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) + } + ingester := appview.Ingester{ Db: wrapper, Enforcer: enforcer, @@ -440,6 +445,7 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { Rkey: rkey, Description: description, Created: time.Now(), + Labels: models.DefaultLabelDefs(), } record := repo.AsRecord() @@ -580,3 +586,30 @@ func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) }) return err } + +func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { + defaults := models.DefaultLabelDefs() + defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) + if err != nil { + return err + } + // already present + if len(defaultLabels) == len(defaults) { + return nil + } + + labelDefs, err := models.FetchDefaultDefs(r) + if err != nil { + return err + } + + // Insert each label definition to the database + for _, labelDef := range labelDefs { + _, err = db.AddLabelDefinition(e, &labelDef) + if err != nil { + return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) + } + } + + return nil +} -- 2.43.0