···966 rp.pages.HxRefresh(w)
967}
968969+func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) {
970 user := rp.oauth.GetUser(r)
971 l := rp.logger.With("handler", "AddLabel")
972 l = l.With("did", user.Did)
···989 concreteType := r.FormValue("valueType")
990 valueFormat := r.FormValue("valueFormat")
991 enumValues := r.FormValue("enumValues")
992+ scope := r.Form["scope"]
993 color := r.FormValue("color")
994 multiple := r.FormValue("multiple") == "true"
995···1000 }
1001 }
10021003+ if concreteType == "" {
1004+ concreteType = "null"
1005+ }
1006+1007 format := db.ValueTypeFormatAny
1008 if valueFormat == "did" {
1009 format = db.ValueTypeFormatDid
···1020 Rkey: tid.TID(),
1021 Name: name,
1022 ValueType: valueType,
1023+ Scope: scope,
1024 Color: &color,
1025 Multiple: multiple,
1026 Created: time.Now(),
···1076 Val: &repoRecord,
1077 },
1078 })
1079+ if err != nil {
1080+ fail("Failed to update labels for repo.", err)
1081+ return
1082+ }
10831084 tx, err := rp.db.BeginTx(r.Context(), nil)
1085 if err != nil {
···1126 rp.pages.HxRefresh(w)
1127}
11281129+func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) {
1130 user := rp.oauth.GetUser(r)
1131 l := rp.logger.With("handler", "DeleteLabel")
1132 l = l.With("did", user.Did)
···12371238func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) {
1239 user := rp.oauth.GetUser(r)
1240+ l := rp.logger.With("handler", "SubscribeLabel")
1241 l = l.With("did", user.Did)
1242 l = l.With("handle", user.Handle)
1243···1247 return
1248 }
12491250+ errorId := "default-label-operation"
1251 fail := func(msg string, err error) {
1252 l.Error(msg, "err", err)
1253 rp.pages.Notice(w, errorId, msg)
···13001301func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) {
1302 user := rp.oauth.GetUser(r)
1303+ l := rp.logger.With("handler", "UnsubscribeLabel")
1304 l = l.With("did", user.Did)
1305 l = l.With("handle", user.Handle)
1306···1310 return
1311 }
13121313+ errorId := "default-label-operation"
1314 fail := func(msg string, err error) {
1315 l.Error(msg, "err", err)
1316 rp.pages.Notice(w, errorId, msg)
···1369 rp.pages.HxRefresh(w)
1370}
13711372+func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) {
1373+ l := rp.logger.With("handler", "LabelPanel")
1374+1375+ f, err := rp.repoResolver.Resolve(r)
1376+ if err != nil {
1377+ l.Error("failed to get repo and knot", "err", err)
1378+ return
1379+ }
1380+1381+ subjectStr := r.FormValue("subject")
1382+ subject, err := syntax.ParseATURI(subjectStr)
1383+ if err != nil {
1384+ l.Error("failed to get repo and knot", "err", err)
1385+ return
1386+ }
1387+1388+ labelDefs, err := db.GetLabelDefinitions(
1389+ rp.db,
1390+ db.FilterIn("at_uri", f.Repo.Labels),
1391+ db.FilterContains("scope", subject.Collection().String()),
1392+ )
1393+ if err != nil {
1394+ log.Println("failed to fetch label defs", err)
1395+ return
1396+ }
1397+1398+ defs := make(map[string]*db.LabelDefinition)
1399+ for _, l := range labelDefs {
1400+ defs[l.AtUri().String()] = &l
1401+ }
1402+1403+ states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1404+ if err != nil {
1405+ log.Println("failed to build label state", err)
1406+ return
1407+ }
1408+ state := states[subject]
1409+1410+ user := rp.oauth.GetUser(r)
1411+ rp.pages.LabelPanel(w, pages.LabelPanelParams{
1412+ LoggedInUser: user,
1413+ RepoInfo: f.RepoInfo(user),
1414+ Defs: defs,
1415+ Subject: subject.String(),
1416+ State: state,
1417+ })
1418+}
1419+1420+func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) {
1421+ l := rp.logger.With("handler", "EditLabelPanel")
1422+1423+ f, err := rp.repoResolver.Resolve(r)
1424+ if err != nil {
1425+ l.Error("failed to get repo and knot", "err", err)
1426+ return
1427+ }
1428+1429+ subjectStr := r.FormValue("subject")
1430+ subject, err := syntax.ParseATURI(subjectStr)
1431+ if err != nil {
1432+ l.Error("failed to get repo and knot", "err", err)
1433+ return
1434+ }
1435+1436+ labelDefs, err := db.GetLabelDefinitions(
1437+ rp.db,
1438+ db.FilterIn("at_uri", f.Repo.Labels),
1439+ db.FilterContains("scope", subject.Collection().String()),
1440+ )
1441+ if err != nil {
1442+ log.Println("failed to fetch labels", err)
1443+ return
1444+ }
1445+1446+ defs := make(map[string]*db.LabelDefinition)
1447+ for _, l := range labelDefs {
1448+ defs[l.AtUri().String()] = &l
1449+ }
1450+1451+ states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1452+ if err != nil {
1453+ log.Println("failed to build label state", err)
1454+ return
1455+ }
1456+ state := states[subject]
1457+1458+ user := rp.oauth.GetUser(r)
1459+ rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
1460+ LoggedInUser: user,
1461+ RepoInfo: f.RepoInfo(user),
1462+ Defs: defs,
1463+ Subject: subject.String(),
1464+ State: state,
1465+ })
1466+}
1467+1468func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
1469 user := rp.oauth.GetUser(r)
1470 l := rp.logger.With("handler", "AddCollaborator")
···1894 return
1895 }
18961897+ defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", db.DefaultLabelDefs()))
1898+ if err != nil {
1899+ log.Println("failed to fetch labels", err)
1900+ rp.pages.Error503(w)
1901+ return
1902+ }
1903+1904 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1905 if err != nil {
1906 log.Println("failed to fetch labels", err)
1907 rp.pages.Error503(w)
1908 return
1909 }
1910+ // remove default labels from the labels list, if present
1911+ defaultLabelMap := make(map[string]bool)
1912+ for _, dl := range defaultLabels {
1913+ defaultLabelMap[dl.AtUri().String()] = true
1914+ }
1915+ n := 0
1916+ for _, l := range labels {
1917+ if !defaultLabelMap[l.AtUri().String()] {
1918+ labels[n] = l
1919+ n++
1920+ }
1921+ }
1922+ labels = labels[:n]
1923+1924+ subscribedLabels := make(map[string]struct{})
1925+ for _, l := range f.Repo.Labels {
1926+ subscribedLabels[l] = struct{}{}
1927+ }
19281929 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1930+ LoggedInUser: user,
1931+ RepoInfo: f.RepoInfo(user),
1932+ Branches: result.Branches,
1933+ Labels: labels,
1934+ DefaultLabels: defaultLabels,
1935+ SubscribedLabels: subscribedLabels,
1936+ Tabs: settingsTabs,
1937+ Tab: "general",
1938 })
1939}
1940
+36-12
appview/validator/label.go
···18 // Color should be a valid hex color
19 colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
20 // You can only label issues and pulls presently
21- validScopes = []syntax.NSID{tangled.RepoIssueNSID, tangled.RepoPullNSID}
22)
2324func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error {
···36 }
3738 if !label.ValueType.IsConcreteType() {
39- return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType)
40 }
4142- if label.ValueType.IsNull() && label.ValueType.IsEnumType() {
043 return fmt.Errorf("null type cannot be used in conjunction with enum type")
44 }
0000000000000004546 // validate scope (nsid format)
47- if label.Scope == "" {
48 return fmt.Errorf("scope is required")
49 }
50- if _, err := syntax.ParseNSID(string(label.Scope)); err != nil {
51- return fmt.Errorf("failed to parse scope: %w", err)
52- }
53- if !slices.Contains(validScopes, label.Scope) {
54- return fmt.Errorf("invalid scope: scope must be one of %q", validScopes)
0055 }
5657 // validate color if provided
···116func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error {
117 valueType := labelDef.ValueType
118000000119 switch valueType.Type {
120 case db.ConcreteTypeNull:
121 // For null type, value should be empty
···125126 case db.ConcreteTypeString:
127 // For string type, validate enum constraints if present
128- if valueType.IsEnumType() {
129 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
130 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
131 }
···153 return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue)
154 }
155156- if valueType.IsEnumType() {
157 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
158 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
159 }
···165 }
166167 // validate enum constraints if present (though uncommon for booleans)
168- if valueType.IsEnumType() {
169 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
170 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
171 }
···18 // Color should be a valid hex color
19 colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
20 // You can only label issues and pulls presently
21+ validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID}
22)
2324func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error {
···36 }
3738 if !label.ValueType.IsConcreteType() {
39+ return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type)
40 }
4142+ // null type checks: cannot be enums, multiple or explicit format
43+ if label.ValueType.IsNull() && label.ValueType.IsEnum() {
44 return fmt.Errorf("null type cannot be used in conjunction with enum type")
45 }
46+ if label.ValueType.IsNull() && label.Multiple {
47+ return fmt.Errorf("null type labels cannot be multiple")
48+ }
49+ if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() {
50+ return fmt.Errorf("format cannot be used in conjunction with null type")
51+ }
52+53+ // format checks: cannot be used with enum, or integers
54+ if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() {
55+ return fmt.Errorf("enum types cannot be used in conjunction with format specification")
56+ }
57+58+ if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() {
59+ return fmt.Errorf("format specifications are only permitted on string types")
60+ }
6162 // validate scope (nsid format)
63+ if label.Scope == nil {
64 return fmt.Errorf("scope is required")
65 }
66+ for _, s := range label.Scope {
67+ if _, err := syntax.ParseNSID(s); err != nil {
68+ return fmt.Errorf("failed to parse scope: %w", err)
69+ }
70+ if !slices.Contains(validScopes, s) {
71+ return fmt.Errorf("invalid scope: scope must be present in %q", validScopes)
72+ }
73 }
7475 // validate color if provided
···134func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error {
135 valueType := labelDef.ValueType
136137+ // this is permitted, it "unsets" a label
138+ if labelOp.OperandValue == "" {
139+ labelOp.Operation = db.LabelOperationDel
140+ return nil
141+ }
142+143 switch valueType.Type {
144 case db.ConcreteTypeNull:
145 // For null type, value should be empty
···149150 case db.ConcreteTypeString:
151 // For string type, validate enum constraints if present
152+ if valueType.IsEnum() {
153 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
154 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
155 }
···177 return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue)
178 }
179180+ if valueType.IsEnum() {
181 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
182 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
183 }
···189 }
190191 // validate enum constraints if present (though uncommon for booleans)
192+ if valueType.IsEnum() {
193 if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
194 return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
195 }