Monorepo for Tangled tangled.org

appview/labels: change scope to be a list of NSIDs

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li 68d49b36 f1dba362

verified
Changed files
+195 -35
appview
db
issues
repo
validator
+13 -8
appview/db/db.go
··· 483 )), 484 value_format text not null default "any", 485 value_enum text, -- comma separated list 486 - scope text not null, 487 color text, 488 multiple integer not null default 0, 489 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), ··· 996 } 997 } 998 999 - func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1000 - func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1001 - func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1002 - func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1003 - func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1004 - func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1005 - func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1006 1007 func (f filter) Condition() string { 1008 rv := reflect.ValueOf(f.arg)
··· 483 )), 484 value_format text not null default "any", 485 value_enum text, -- comma separated list 486 + scope text not null, -- comma separated list of nsid 487 color text, 488 multiple integer not null default 0, 489 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), ··· 996 } 997 } 998 999 + func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1000 + func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1001 + func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1002 + func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1003 + func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1004 + func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1005 + func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1006 + func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) } 1007 + func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) } 1008 + func FilterContains(key string, arg any) filter { 1009 + return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 1010 + } 1011 1012 func (f filter) Condition() string { 1013 rv := reflect.ValueOf(f.arg)
+1 -1
appview/issues/issues.go
··· 95 labelDefs, err := db.GetLabelDefinitions( 96 rp.db, 97 db.FilterIn("at_uri", f.Repo.Labels), 98 - db.FilterEq("scope", tangled.RepoIssueNSID), 99 ) 100 if err != nil { 101 log.Println("failed to fetch labels", err)
··· 95 labelDefs, err := db.GetLabelDefinitions( 96 rp.db, 97 db.FilterIn("at_uri", f.Repo.Labels), 98 + db.FilterContains("scope", tangled.RepoIssueNSID), 99 ) 100 if err != nil { 101 log.Println("failed to fetch labels", err)
+145 -14
appview/repo/repo.go
··· 966 rp.pages.HxRefresh(w) 967 } 968 969 - func (rp *Repo) AddLabel(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.FormValue("scope") 993 color := r.FormValue("color") 994 multiple := r.FormValue("multiple") == "true" 995 ··· 1000 } 1001 } 1002 1003 format := db.ValueTypeFormatAny 1004 if valueFormat == "did" { 1005 format = db.ValueTypeFormatDid ··· 1016 Rkey: tid.TID(), 1017 Name: name, 1018 ValueType: valueType, 1019 - Scope: syntax.NSID(scope), 1020 Color: &color, 1021 Multiple: multiple, 1022 Created: time.Now(), ··· 1072 Val: &repoRecord, 1073 }, 1074 }) 1075 1076 tx, err := rp.db.BeginTx(r.Context(), nil) 1077 if err != nil { ··· 1118 rp.pages.HxRefresh(w) 1119 } 1120 1121 - func (rp *Repo) DeleteLabel(w http.ResponseWriter, r *http.Request) { 1122 user := rp.oauth.GetUser(r) 1123 l := rp.logger.With("handler", "DeleteLabel") 1124 l = l.With("did", user.Did) ··· 1229 1230 func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { 1231 user := rp.oauth.GetUser(r) 1232 - l := rp.logger.With("handler", "DeleteLabel") 1233 l = l.With("did", user.Did) 1234 l = l.With("handle", user.Handle) 1235 ··· 1239 return 1240 } 1241 1242 - errorId := "label-operation" 1243 fail := func(msg string, err error) { 1244 l.Error(msg, "err", err) 1245 rp.pages.Notice(w, errorId, msg) ··· 1292 1293 func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { 1294 user := rp.oauth.GetUser(r) 1295 - l := rp.logger.With("handler", "DeleteLabel") 1296 l = l.With("did", user.Did) 1297 l = l.With("handle", user.Handle) 1298 ··· 1302 return 1303 } 1304 1305 - errorId := "label-operation" 1306 fail := func(msg string, err error) { 1307 l.Error(msg, "err", err) 1308 rp.pages.Notice(w, errorId, msg) ··· 1361 rp.pages.HxRefresh(w) 1362 } 1363 1364 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 1365 user := rp.oauth.GetUser(r) 1366 l := rp.logger.With("handler", "AddCollaborator") ··· 1790 return 1791 } 1792 1793 labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1794 if err != nil { 1795 log.Println("failed to fetch labels", err) 1796 rp.pages.Error503(w) 1797 return 1798 } 1799 1800 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1801 - LoggedInUser: user, 1802 - RepoInfo: f.RepoInfo(user), 1803 - Branches: result.Branches, 1804 - Labels: labels, 1805 - Tabs: settingsTabs, 1806 - Tab: "general", 1807 }) 1808 } 1809
··· 966 rp.pages.HxRefresh(w) 967 } 968 969 + 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 } 1002 1003 + 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 + } 1083 1084 tx, err := rp.db.BeginTx(r.Context(), nil) 1085 if err != nil { ··· 1126 rp.pages.HxRefresh(w) 1127 } 1128 1129 + 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) ··· 1237 1238 func (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 } 1249 1250 + errorId := "default-label-operation" 1251 fail := func(msg string, err error) { 1252 l.Error(msg, "err", err) 1253 rp.pages.Notice(w, errorId, msg) ··· 1300 1301 func (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 } 1312 1313 + 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 } 1371 1372 + 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 + 1468 func (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 } 1896 1897 + 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 + } 1928 1929 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 ) 23 24 func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { ··· 36 } 37 38 if !label.ValueType.IsConcreteType() { 39 - return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType) 40 } 41 42 - if label.ValueType.IsNull() && label.ValueType.IsEnumType() { 43 return fmt.Errorf("null type cannot be used in conjunction with enum type") 44 } 45 46 // 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) 55 } 56 57 // validate color if provided ··· 116 func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { 117 valueType := labelDef.ValueType 118 119 switch valueType.Type { 120 case db.ConcreteTypeNull: 121 // For null type, value should be empty ··· 125 126 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 } 155 156 - 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 } 166 167 // 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 ) 23 24 func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { ··· 36 } 37 38 if !label.ValueType.IsConcreteType() { 39 + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) 40 } 41 42 + // 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 + } 61 62 // 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 } 74 75 // validate color if provided ··· 134 func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { 135 valueType := labelDef.ValueType 136 137 + // 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 ··· 149 150 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 } 179 180 + 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 } 190 191 // 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 }