diff --git a/appview/db/db.go b/appview/db/db.go --- a/appview/db/db.go +++ b/appview/db/db.go @@ -483,7 +483,7 @@ )), 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 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 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -95,7 +95,7 @@ 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 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -966,7 +966,7 @@ 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 @@ 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" @@ -998,6 +998,10 @@ if part = strings.TrimSpace(part); part != "" { variants = append(variants, part) } + } + + if concreteType == "" { + concreteType = "null" } format := db.ValueTypeFormatAny @@ -1016,7 +1020,7 @@ Rkey: tid.TID(), Name: name, ValueType: valueType, - Scope: syntax.NSID(scope), + Scope: scope, Color: &color, Multiple: multiple, Created: time.Now(), @@ -1072,6 +1076,10 @@ 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 @@ 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) 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 @@ 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) 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 @@ 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) @@ -1359,6 +1367,102 @@ // everything succeeded 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) { @@ -1790,20 +1894,47 @@ return } - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) + 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 --- a/appview/validator/label.go +++ b/appview/validator/label.go @@ -18,7 +18,7 @@ // 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 @@ } 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) 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 @@ 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 @@ 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 @@ } // 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) }