From 983781b1d83c99e285b8b7af2bf974f0684e9fe6 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Tue, 16 Sep 2025 14:46:02 +0100 Subject: [PATCH] appview/labels: change scope to be a list of NSIDs Signed-off-by: oppiliappan --- appview/db/db.go | 21 +++-- appview/issues/issues.go | 2 +- appview/repo/repo.go | 159 +++++++++++++++++++++++++++++++++---- appview/validator/label.go | 48 ++++++++--- 4 files changed, 195 insertions(+), 35 deletions(-) diff --git a/appview/db/db.go b/appview/db/db.go index 97a8427d..58fa50f0 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -483,7 +483,7 @@ func Make(dbPath string) (*DB, error) { )), value_format text not null default "any", value_enum text, -- comma separated list - scope text not null, + scope text not null, -- comma separated list of nsid color text, multiple integer not null default 0, created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), @@ -996,13 +996,18 @@ func newFilter(key, cmp string, arg any) filter { } } -func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } -func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } -func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } -func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } -func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } -func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } -func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } +func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } +func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } +func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } +func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } +func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } +func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } +func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } +func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) } +func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) } +func FilterContains(key string, arg any) filter { + return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) +} func (f filter) Condition() string { rv := reflect.ValueOf(f.arg) diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 8430668d..c8ab3959 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -95,7 +95,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { labelDefs, err := db.GetLabelDefinitions( rp.db, db.FilterIn("at_uri", f.Repo.Labels), - db.FilterEq("scope", tangled.RepoIssueNSID), + db.FilterContains("scope", tangled.RepoIssueNSID), ) if err != nil { log.Println("failed to fetch labels", err) diff --git a/appview/repo/repo.go b/appview/repo/repo.go index f28ac61d..804dcc8c 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -966,7 +966,7 @@ func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { rp.pages.HxRefresh(w) } -func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { +func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "AddLabel") l = l.With("did", user.Did) @@ -989,7 +989,7 @@ func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { concreteType := r.FormValue("valueType") valueFormat := r.FormValue("valueFormat") enumValues := r.FormValue("enumValues") - scope := r.FormValue("scope") + scope := r.Form["scope"] color := r.FormValue("color") multiple := r.FormValue("multiple") == "true" @@ -1000,6 +1000,10 @@ func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { } } + if concreteType == "" { + concreteType = "null" + } + format := db.ValueTypeFormatAny if valueFormat == "did" { format = db.ValueTypeFormatDid @@ -1016,7 +1020,7 @@ func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { Rkey: tid.TID(), Name: name, ValueType: valueType, - Scope: syntax.NSID(scope), + Scope: scope, Color: &color, Multiple: multiple, Created: time.Now(), @@ -1072,6 +1076,10 @@ func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { Val: &repoRecord, }, }) + if err != nil { + fail("Failed to update labels for repo.", err) + return + } tx, err := rp.db.BeginTx(r.Context(), nil) if err != nil { @@ -1118,7 +1126,7 @@ func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { rp.pages.HxRefresh(w) } -func (rp *Repo) DeleteLabel(w http.ResponseWriter, r *http.Request) { +func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "DeleteLabel") l = l.With("did", user.Did) @@ -1229,7 +1237,7 @@ func (rp *Repo) DeleteLabel(w http.ResponseWriter, r *http.Request) { func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) - l := rp.logger.With("handler", "DeleteLabel") + l := rp.logger.With("handler", "SubscribeLabel") l = l.With("did", user.Did) l = l.With("handle", user.Handle) @@ -1239,7 +1247,7 @@ func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { return } - errorId := "label-operation" + errorId := "default-label-operation" fail := func(msg string, err error) { l.Error(msg, "err", err) rp.pages.Notice(w, errorId, msg) @@ -1292,7 +1300,7 @@ func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) - l := rp.logger.With("handler", "DeleteLabel") + l := rp.logger.With("handler", "UnsubscribeLabel") l = l.With("did", user.Did) l = l.With("handle", user.Handle) @@ -1302,7 +1310,7 @@ func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { return } - errorId := "label-operation" + errorId := "default-label-operation" fail := func(msg string, err error) { l.Error(msg, "err", err) rp.pages.Notice(w, errorId, msg) @@ -1361,6 +1369,102 @@ func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { rp.pages.HxRefresh(w) } +func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "LabelPanel") + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + subjectStr := r.FormValue("subject") + subject, err := syntax.ParseATURI(subjectStr) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + labelDefs, err := db.GetLabelDefinitions( + rp.db, + db.FilterIn("at_uri", f.Repo.Labels), + db.FilterContains("scope", subject.Collection().String()), + ) + if err != nil { + log.Println("failed to fetch label defs", err) + return + } + + defs := make(map[string]*db.LabelDefinition) + for _, l := range labelDefs { + defs[l.AtUri().String()] = &l + } + + states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) + if err != nil { + log.Println("failed to build label state", err) + return + } + state := states[subject] + + user := rp.oauth.GetUser(r) + rp.pages.LabelPanel(w, pages.LabelPanelParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Defs: defs, + Subject: subject.String(), + State: state, + }) +} + +func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "EditLabelPanel") + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + subjectStr := r.FormValue("subject") + subject, err := syntax.ParseATURI(subjectStr) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + + labelDefs, err := db.GetLabelDefinitions( + rp.db, + db.FilterIn("at_uri", f.Repo.Labels), + db.FilterContains("scope", subject.Collection().String()), + ) + if err != nil { + log.Println("failed to fetch labels", err) + return + } + + defs := make(map[string]*db.LabelDefinition) + for _, l := range labelDefs { + defs[l.AtUri().String()] = &l + } + + states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) + if err != nil { + log.Println("failed to build label state", err) + return + } + state := states[subject] + + user := rp.oauth.GetUser(r) + rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Defs: defs, + Subject: subject.String(), + State: state, + }) +} + func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "AddCollaborator") @@ -1790,20 +1894,47 @@ func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { return } + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", db.DefaultLabelDefs())) + if err != nil { + log.Println("failed to fetch labels", err) + rp.pages.Error503(w) + return + } + labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) if err != nil { log.Println("failed to fetch labels", err) rp.pages.Error503(w) return } + // remove default labels from the labels list, if present + defaultLabelMap := make(map[string]bool) + for _, dl := range defaultLabels { + defaultLabelMap[dl.AtUri().String()] = true + } + n := 0 + for _, l := range labels { + if !defaultLabelMap[l.AtUri().String()] { + labels[n] = l + n++ + } + } + labels = labels[:n] + + subscribedLabels := make(map[string]struct{}) + for _, l := range f.Repo.Labels { + subscribedLabels[l] = struct{}{} + } rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - Branches: result.Branches, - Labels: labels, - Tabs: settingsTabs, - Tab: "general", + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Branches: result.Branches, + Labels: labels, + DefaultLabels: defaultLabels, + SubscribedLabels: subscribedLabels, + Tabs: settingsTabs, + Tab: "general", }) } diff --git a/appview/validator/label.go b/appview/validator/label.go index e4732ca1..86426538 100644 --- a/appview/validator/label.go +++ b/appview/validator/label.go @@ -18,7 +18,7 @@ var ( // Color should be a valid hex color colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) // You can only label issues and pulls presently - validScopes = []syntax.NSID{tangled.RepoIssueNSID, tangled.RepoPullNSID} + validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} ) func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { @@ -36,22 +36,40 @@ func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { } if !label.ValueType.IsConcreteType() { - return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType) + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) } - if label.ValueType.IsNull() && label.ValueType.IsEnumType() { + // null type checks: cannot be enums, multiple or explicit format + if label.ValueType.IsNull() && label.ValueType.IsEnum() { return fmt.Errorf("null type cannot be used in conjunction with enum type") } + if label.ValueType.IsNull() && label.Multiple { + return fmt.Errorf("null type labels cannot be multiple") + } + if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() { + return fmt.Errorf("format cannot be used in conjunction with null type") + } + + // format checks: cannot be used with enum, or integers + if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() { + return fmt.Errorf("enum types cannot be used in conjunction with format specification") + } + + if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() { + return fmt.Errorf("format specifications are only permitted on string types") + } // validate scope (nsid format) - if label.Scope == "" { + if label.Scope == nil { return fmt.Errorf("scope is required") } - if _, err := syntax.ParseNSID(string(label.Scope)); err != nil { - return fmt.Errorf("failed to parse scope: %w", err) - } - if !slices.Contains(validScopes, label.Scope) { - return fmt.Errorf("invalid scope: scope must be one of %q", validScopes) + for _, s := range label.Scope { + if _, err := syntax.ParseNSID(s); err != nil { + return fmt.Errorf("failed to parse scope: %w", err) + } + if !slices.Contains(validScopes, s) { + return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) + } } // validate color if provided @@ -116,6 +134,12 @@ func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.La func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { valueType := labelDef.ValueType + // this is permitted, it "unsets" a label + if labelOp.OperandValue == "" { + labelOp.Operation = db.LabelOperationDel + return nil + } + switch valueType.Type { case db.ConcreteTypeNull: // For null type, value should be empty @@ -125,7 +149,7 @@ func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp * case db.ConcreteTypeString: // For string type, validate enum constraints if present - if valueType.IsEnumType() { + if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) } @@ -153,7 +177,7 @@ func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp * return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) } - if valueType.IsEnumType() { + if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) } @@ -165,7 +189,7 @@ func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp * } // validate enum constraints if present (though uncommon for booleans) - if valueType.IsEnumType() { + if valueType.IsEnum() { if !slices.Contains(valueType.Enum, labelOp.OperandValue) { return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) } -- 2.51.2