Monorepo for Tangled tangled.org

appview/repo: add handlers to add/del label definitions

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

oppi.li b3c1e3dd 339a78e7

verified
Changed files
+368 -3
appview
pages
templates
repo
repo
state
+104
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
··· 1 + {{ define "repo/settings/fragments/addLabelDefModal" }} 2 + <form 3 + hx-put="/{{ $.RepoInfo.FullName }}/settings/label" 4 + hx-indicator="#spinner" 5 + hx-swap="none" 6 + class="flex flex-col gap-4" 7 + > 8 + <p class="text-gray-500 dark:text-gray-400">Labels can have a name and a value. Set the value type to "none" to create a simple label.</p> 9 + 10 + <div class="w-full"> 11 + <label for="name">Name</label> 12 + <input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/> 13 + </div> 14 + 15 + <!-- Value Type --> 16 + <div class="w-full"> 17 + <label for="valueType">Value Type</label> 18 + <select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 19 + <option value="string" selected>String</option> 20 + <option value="integer">Integer</option> 21 + <option value="boolean">Boolean</option> 22 + <option value="null">None</option> 23 + </select> 24 + <details id="constrain-values" class="group"> 25 + <summary class="list-none cursor-pointer flex items-center gap-2 py-2"> 26 + <span class="group-open:hidden inline text-gray-500 dark:text-gray-400">{{ i "square-plus" "w-4 h-4" }}</span> 27 + <span class="hidden group-open:inline text-gray-500 dark:text-gray-400">{{ i "square-minus" "w-4 h-4" }}</span> 28 + <span>Constrain values</span> 29 + </summary> 30 + <input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/> 31 + <p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Enter comma-separated list of permitted values.</p> 32 + </details> 33 + </div> 34 + 35 + <!-- Scope --> 36 + <div class="w-full"> 37 + <label for="scope">Scope</label> 38 + <select id="scope" name="scope" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 39 + <option value="sh.tangled.repo.issue">Issues</option> 40 + <option value="sh.tangled.repo.pull">Pull Requests</option> 41 + </select> 42 + </div> 43 + 44 + <!-- Color --> 45 + <div class="w-full"> 46 + <label for="color">Color</label> 47 + <div class="grid grid-cols-4 grid-rows-2 place-items-center"> 48 + {{ $colors := list "#ef4444" "#3b82f6" "#10b981" "#f59e0b" "#8b5cf6" "#ec4899" "#06b6d4" "#64748b" }} 49 + {{ range $i, $color := $colors }} 50 + <label class="relative"> 51 + <input type="radio" name="color" value="{{ $color }}" class="sr-only peer" {{ if eq $i 0 }} checked {{ end }}> 52 + {{ template "repo/fragments/colorBall" (dict "color" $color "classes" "size-4 peer-checked:size-8 transition-all") }} 53 + </label> 54 + {{ end }} 55 + </div> 56 + </div> 57 + 58 + <!-- Multiple --> 59 + <div class="w-full flex flex-wrap gap-2"> 60 + <input type="checkbox" id="multiple" name="multiple" value="true" /> 61 + <span> 62 + Allow multiple values 63 + </span> 64 + </div> 65 + 66 + <div class="flex gap-2 pt-2"> 67 + <button 68 + type="button" 69 + popovertarget="add-labeldef-modal" 70 + popovertargetaction="hide" 71 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 72 + > 73 + {{ i "x" "size-4" }} cancel 74 + </button> 75 + <button type="submit" class="btn w-1/2 flex items-center"> 76 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 77 + <span id="spinner" class="group"> 78 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 79 + </span> 80 + </button> 81 + </div> 82 + <div id="add-label-error" class="text-red-500 dark:text-red-400"></div> 83 + </form> 84 + 85 + <script> 86 + document.getElementById('value-type').addEventListener('change', function() { 87 + const constrainValues = document.getElementById('constrain-values'); 88 + const selectedValue = this.value; 89 + 90 + if (selectedValue === 'string' || selectedValue === 'integer') { 91 + constrainValues.classList.remove('hidden'); 92 + } else { 93 + constrainValues.classList.add('hidden'); 94 + constrainValues.removeAttribute('open'); 95 + document.getElementById('enumValues').value = ''; 96 + } 97 + }); 98 + 99 + function toggleDarkMode() { 100 + document.documentElement.classList.toggle('dark'); 101 + } 102 + </script> 103 + {{ end }} 104 +
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 109 109 hx-swap="none" 110 110 class="flex flex-col gap-2" 111 111 > 112 - <p class="uppercase p-0">ADD SECRET</p> 112 + <p class="uppercase p-0 font-bold">ADD SECRET</p> 113 113 <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 114 <input 115 115 type="text"
+260 -1
appview/repo/repo.go
··· 29 29 "tangled.sh/tangled.sh/core/appview/pages" 30 30 "tangled.sh/tangled.sh/core/appview/pages/markup" 31 31 "tangled.sh/tangled.sh/core/appview/reporesolver" 32 + "tangled.sh/tangled.sh/core/appview/validator" 32 33 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 33 34 "tangled.sh/tangled.sh/core/eventconsumer" 34 35 "tangled.sh/tangled.sh/core/idresolver" ··· 57 58 notifier notify.Notifier 58 59 logger *slog.Logger 59 60 serviceAuth *serviceauth.ServiceAuth 61 + validator *validator.Validator 60 62 } 61 63 62 64 func New( ··· 70 72 notifier notify.Notifier, 71 73 enforcer *rbac.Enforcer, 72 74 logger *slog.Logger, 75 + validator *validator.Validator, 73 76 ) *Repo { 74 77 return &Repo{oauth: oauth, 75 78 repoResolver: repoResolver, ··· 81 84 notifier: notifier, 82 85 enforcer: enforcer, 83 86 logger: logger, 87 + validator: validator, 84 88 } 85 89 } 86 90 ··· 938 942 rp.pages.HxRefresh(w) 939 943 } 940 944 945 + func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) { 946 + user := rp.oauth.GetUser(r) 947 + l := rp.logger.With("handler", "AddLabel") 948 + l = l.With("did", user.Did) 949 + l = l.With("handle", user.Handle) 950 + 951 + f, err := rp.repoResolver.Resolve(r) 952 + if err != nil { 953 + l.Error("failed to get repo and knot", "err", err) 954 + return 955 + } 956 + 957 + errorId := "add-label-error" 958 + fail := func(msg string, err error) { 959 + l.Error(msg, "err", err) 960 + rp.pages.Notice(w, errorId, msg) 961 + } 962 + 963 + // get form values for label definition 964 + name := r.FormValue("name") 965 + concreteType := r.FormValue("valueType") 966 + enumValues := r.FormValue("enumValues") 967 + scope := r.FormValue("scope") 968 + color := r.FormValue("color") 969 + multiple := r.FormValue("multiple") == "true" 970 + 971 + var variants []string 972 + for part := range strings.SplitSeq(enumValues, ",") { 973 + if part = strings.TrimSpace(part); part != "" { 974 + variants = append(variants, part) 975 + } 976 + } 977 + 978 + valueType := db.ValueType{ 979 + Type: db.ConcreteType(concreteType), 980 + Format: db.ValueTypeFormatAny, 981 + Enum: variants, 982 + } 983 + 984 + label := db.LabelDefinition{ 985 + Did: user.Did, 986 + Rkey: tid.TID(), 987 + Name: name, 988 + ValueType: valueType, 989 + Scope: syntax.NSID(scope), 990 + Color: &color, 991 + Multiple: multiple, 992 + Created: time.Now(), 993 + } 994 + if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 995 + fail(err.Error(), err) 996 + return 997 + } 998 + 999 + // announce this relation into the firehose, store into owners' pds 1000 + client, err := rp.oauth.AuthorizedClient(r) 1001 + if err != nil { 1002 + fail(err.Error(), err) 1003 + return 1004 + } 1005 + 1006 + // emit a labelRecord 1007 + labelRecord := label.AsRecord() 1008 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1009 + Collection: tangled.LabelDefinitionNSID, 1010 + Repo: label.Did, 1011 + Rkey: label.Rkey, 1012 + Record: &lexutil.LexiconTypeDecoder{ 1013 + Val: &labelRecord, 1014 + }, 1015 + }) 1016 + // invalid record 1017 + if err != nil { 1018 + fail("Failed to write record to PDS.", err) 1019 + return 1020 + } 1021 + 1022 + aturi := resp.Uri 1023 + l = l.With("at-uri", aturi) 1024 + l.Info("wrote label record to PDS") 1025 + 1026 + // update the repo to subscribe to this label 1027 + newRepo := f.Repo 1028 + newRepo.Labels = append(newRepo.Labels, aturi) 1029 + repoRecord := newRepo.AsRecord() 1030 + 1031 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1032 + if err != nil { 1033 + fail("Failed to update labels, no record found on PDS.", err) 1034 + return 1035 + } 1036 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1037 + Collection: tangled.RepoNSID, 1038 + Repo: newRepo.Did, 1039 + Rkey: newRepo.Rkey, 1040 + SwapRecord: ex.Cid, 1041 + Record: &lexutil.LexiconTypeDecoder{ 1042 + Val: &repoRecord, 1043 + }, 1044 + }) 1045 + 1046 + tx, err := rp.db.BeginTx(r.Context(), nil) 1047 + if err != nil { 1048 + fail("Failed to add label.", err) 1049 + return 1050 + } 1051 + 1052 + rollback := func() { 1053 + err1 := tx.Rollback() 1054 + err2 := rollbackRecord(context.Background(), aturi, client) 1055 + 1056 + // ignore txn complete errors, this is okay 1057 + if errors.Is(err1, sql.ErrTxDone) { 1058 + err1 = nil 1059 + } 1060 + 1061 + if errs := errors.Join(err1, err2); errs != nil { 1062 + l.Error("failed to rollback changes", "errs", errs) 1063 + return 1064 + } 1065 + } 1066 + defer rollback() 1067 + 1068 + _, err = db.AddLabelDefinition(tx, &label) 1069 + if err != nil { 1070 + fail("Failed to add label.", err) 1071 + return 1072 + } 1073 + 1074 + err = db.SubscribeLabel(tx, &db.RepoLabel{ 1075 + RepoAt: f.RepoAt(), 1076 + LabelAt: label.AtUri(), 1077 + }) 1078 + 1079 + err = tx.Commit() 1080 + if err != nil { 1081 + fail("Failed to add label.", err) 1082 + return 1083 + } 1084 + 1085 + // clear aturi when everything is successful 1086 + aturi = "" 1087 + 1088 + rp.pages.HxRefresh(w) 1089 + } 1090 + 1091 + func (rp *Repo) DeleteLabel(w http.ResponseWriter, r *http.Request) { 1092 + user := rp.oauth.GetUser(r) 1093 + l := rp.logger.With("handler", "DeleteLabel") 1094 + l = l.With("did", user.Did) 1095 + l = l.With("handle", user.Handle) 1096 + 1097 + f, err := rp.repoResolver.Resolve(r) 1098 + if err != nil { 1099 + l.Error("failed to get repo and knot", "err", err) 1100 + return 1101 + } 1102 + 1103 + errorId := "label-operation" 1104 + fail := func(msg string, err error) { 1105 + l.Error(msg, "err", err) 1106 + rp.pages.Notice(w, errorId, msg) 1107 + } 1108 + 1109 + // get form values 1110 + labelId := r.FormValue("label-id") 1111 + 1112 + label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId)) 1113 + if err != nil { 1114 + fail("Failed to find label definition.", err) 1115 + return 1116 + } 1117 + 1118 + client, err := rp.oauth.AuthorizedClient(r) 1119 + if err != nil { 1120 + fail(err.Error(), err) 1121 + return 1122 + } 1123 + 1124 + // delete label record from PDS 1125 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1126 + Collection: tangled.LabelDefinitionNSID, 1127 + Repo: label.Did, 1128 + Rkey: label.Rkey, 1129 + }) 1130 + if err != nil { 1131 + fail("Failed to delete label record from PDS.", err) 1132 + return 1133 + } 1134 + 1135 + // update repo record to remove the label reference 1136 + newRepo := f.Repo 1137 + var updated []string 1138 + removedAt := label.AtUri().String() 1139 + for _, l := range newRepo.Labels { 1140 + if l != removedAt { 1141 + updated = append(updated, l) 1142 + } 1143 + } 1144 + newRepo.Labels = updated 1145 + repoRecord := newRepo.AsRecord() 1146 + 1147 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1148 + if err != nil { 1149 + fail("Failed to update labels, no record found on PDS.", err) 1150 + return 1151 + } 1152 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1153 + Collection: tangled.RepoNSID, 1154 + Repo: newRepo.Did, 1155 + Rkey: newRepo.Rkey, 1156 + SwapRecord: ex.Cid, 1157 + Record: &lexutil.LexiconTypeDecoder{ 1158 + Val: &repoRecord, 1159 + }, 1160 + }) 1161 + if err != nil { 1162 + fail("Failed to update repo record.", err) 1163 + return 1164 + } 1165 + 1166 + // transaction for DB changes 1167 + tx, err := rp.db.BeginTx(r.Context(), nil) 1168 + if err != nil { 1169 + fail("Failed to delete label.", err) 1170 + return 1171 + } 1172 + defer tx.Rollback() 1173 + 1174 + err = db.UnsubscribeLabel( 1175 + tx, 1176 + db.FilterEq("repo_at", f.RepoAt()), 1177 + db.FilterEq("label_at", removedAt), 1178 + ) 1179 + if err != nil { 1180 + fail("Failed to unsubscribe label.", err) 1181 + return 1182 + } 1183 + 1184 + err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id)) 1185 + if err != nil { 1186 + fail("Failed to delete label definition.", err) 1187 + return 1188 + } 1189 + 1190 + err = tx.Commit() 1191 + if err != nil { 1192 + fail("Failed to delete label.", err) 1193 + return 1194 + } 1195 + 1196 + // everything succeeded 1197 + rp.pages.HxRefresh(w) 1198 + } 1199 + 941 1200 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 942 1201 user := rp.oauth.GetUser(r) 943 1202 l := rp.logger.With("handler", "AddCollaborator") ··· 1565 1824 if errors.Is(err, sql.ErrNoRows) { 1566 1825 // no existing repo with this name found, we can use the name as is 1567 1826 } else { 1568 - log.Println("error fetching existing repo from db", err) 1827 + log.Println("error fetching existing repo from db", "err", err) 1569 1828 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1570 1829 return 1571 1830 }
+2
appview/repo/router.go
··· 76 76 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 77 77 r.Get("/", rp.RepoSettings) 78 78 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 79 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabel) 80 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabel) 79 81 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 80 82 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 81 83 r.Put("/branches/default", rp.SetDefaultBranch)
+1 -1
appview/state/router.go
··· 240 240 241 241 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 242 242 logger := log.New("repo") 243 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 243 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator) 244 244 return repo.Router(mw) 245 245 } 246 246