Monorepo for Tangled tangled.org

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

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

oppi.li 59906263 cba926d3

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.org/core/appview/pages" 30 30 "tangled.org/core/appview/pages/markup" 31 31 "tangled.org/core/appview/reporesolver" 32 + "tangled.org/core/appview/validator" 32 33 xrpcclient "tangled.org/core/appview/xrpcclient" 33 34 "tangled.org/core/eventconsumer" 34 35 "tangled.org/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 ··· 962 966 rp.pages.HxRefresh(w) 963 967 } 964 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) 973 + l = l.With("handle", user.Handle) 974 + 975 + f, err := rp.repoResolver.Resolve(r) 976 + if err != nil { 977 + l.Error("failed to get repo and knot", "err", err) 978 + return 979 + } 980 + 981 + errorId := "add-label-error" 982 + fail := func(msg string, err error) { 983 + l.Error(msg, "err", err) 984 + rp.pages.Notice(w, errorId, msg) 985 + } 986 + 987 + // get form values for label definition 988 + name := r.FormValue("name") 989 + concreteType := r.FormValue("valueType") 990 + enumValues := r.FormValue("enumValues") 991 + scope := r.FormValue("scope") 992 + color := r.FormValue("color") 993 + multiple := r.FormValue("multiple") == "true" 994 + 995 + var variants []string 996 + for part := range strings.SplitSeq(enumValues, ",") { 997 + if part = strings.TrimSpace(part); part != "" { 998 + variants = append(variants, part) 999 + } 1000 + } 1001 + 1002 + valueType := db.ValueType{ 1003 + Type: db.ConcreteType(concreteType), 1004 + Format: db.ValueTypeFormatAny, 1005 + Enum: variants, 1006 + } 1007 + 1008 + label := db.LabelDefinition{ 1009 + Did: user.Did, 1010 + Rkey: tid.TID(), 1011 + Name: name, 1012 + ValueType: valueType, 1013 + Scope: syntax.NSID(scope), 1014 + Color: &color, 1015 + Multiple: multiple, 1016 + Created: time.Now(), 1017 + } 1018 + if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 1019 + fail(err.Error(), err) 1020 + return 1021 + } 1022 + 1023 + // announce this relation into the firehose, store into owners' pds 1024 + client, err := rp.oauth.AuthorizedClient(r) 1025 + if err != nil { 1026 + fail(err.Error(), err) 1027 + return 1028 + } 1029 + 1030 + // emit a labelRecord 1031 + labelRecord := label.AsRecord() 1032 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1033 + Collection: tangled.LabelDefinitionNSID, 1034 + Repo: label.Did, 1035 + Rkey: label.Rkey, 1036 + Record: &lexutil.LexiconTypeDecoder{ 1037 + Val: &labelRecord, 1038 + }, 1039 + }) 1040 + // invalid record 1041 + if err != nil { 1042 + fail("Failed to write record to PDS.", err) 1043 + return 1044 + } 1045 + 1046 + aturi := resp.Uri 1047 + l = l.With("at-uri", aturi) 1048 + l.Info("wrote label record to PDS") 1049 + 1050 + // update the repo to subscribe to this label 1051 + newRepo := f.Repo 1052 + newRepo.Labels = append(newRepo.Labels, aturi) 1053 + repoRecord := newRepo.AsRecord() 1054 + 1055 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1056 + if err != nil { 1057 + fail("Failed to update labels, no record found on PDS.", err) 1058 + return 1059 + } 1060 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1061 + Collection: tangled.RepoNSID, 1062 + Repo: newRepo.Did, 1063 + Rkey: newRepo.Rkey, 1064 + SwapRecord: ex.Cid, 1065 + Record: &lexutil.LexiconTypeDecoder{ 1066 + Val: &repoRecord, 1067 + }, 1068 + }) 1069 + 1070 + tx, err := rp.db.BeginTx(r.Context(), nil) 1071 + if err != nil { 1072 + fail("Failed to add label.", err) 1073 + return 1074 + } 1075 + 1076 + rollback := func() { 1077 + err1 := tx.Rollback() 1078 + err2 := rollbackRecord(context.Background(), aturi, client) 1079 + 1080 + // ignore txn complete errors, this is okay 1081 + if errors.Is(err1, sql.ErrTxDone) { 1082 + err1 = nil 1083 + } 1084 + 1085 + if errs := errors.Join(err1, err2); errs != nil { 1086 + l.Error("failed to rollback changes", "errs", errs) 1087 + return 1088 + } 1089 + } 1090 + defer rollback() 1091 + 1092 + _, err = db.AddLabelDefinition(tx, &label) 1093 + if err != nil { 1094 + fail("Failed to add label.", err) 1095 + return 1096 + } 1097 + 1098 + err = db.SubscribeLabel(tx, &db.RepoLabel{ 1099 + RepoAt: f.RepoAt(), 1100 + LabelAt: label.AtUri(), 1101 + }) 1102 + 1103 + err = tx.Commit() 1104 + if err != nil { 1105 + fail("Failed to add label.", err) 1106 + return 1107 + } 1108 + 1109 + // clear aturi when everything is successful 1110 + aturi = "" 1111 + 1112 + rp.pages.HxRefresh(w) 1113 + } 1114 + 1115 + func (rp *Repo) DeleteLabel(w http.ResponseWriter, r *http.Request) { 1116 + user := rp.oauth.GetUser(r) 1117 + l := rp.logger.With("handler", "DeleteLabel") 1118 + l = l.With("did", user.Did) 1119 + l = l.With("handle", user.Handle) 1120 + 1121 + f, err := rp.repoResolver.Resolve(r) 1122 + if err != nil { 1123 + l.Error("failed to get repo and knot", "err", err) 1124 + return 1125 + } 1126 + 1127 + errorId := "label-operation" 1128 + fail := func(msg string, err error) { 1129 + l.Error(msg, "err", err) 1130 + rp.pages.Notice(w, errorId, msg) 1131 + } 1132 + 1133 + // get form values 1134 + labelId := r.FormValue("label-id") 1135 + 1136 + label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId)) 1137 + if err != nil { 1138 + fail("Failed to find label definition.", err) 1139 + return 1140 + } 1141 + 1142 + client, err := rp.oauth.AuthorizedClient(r) 1143 + if err != nil { 1144 + fail(err.Error(), err) 1145 + return 1146 + } 1147 + 1148 + // delete label record from PDS 1149 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1150 + Collection: tangled.LabelDefinitionNSID, 1151 + Repo: label.Did, 1152 + Rkey: label.Rkey, 1153 + }) 1154 + if err != nil { 1155 + fail("Failed to delete label record from PDS.", err) 1156 + return 1157 + } 1158 + 1159 + // update repo record to remove the label reference 1160 + newRepo := f.Repo 1161 + var updated []string 1162 + removedAt := label.AtUri().String() 1163 + for _, l := range newRepo.Labels { 1164 + if l != removedAt { 1165 + updated = append(updated, l) 1166 + } 1167 + } 1168 + newRepo.Labels = updated 1169 + repoRecord := newRepo.AsRecord() 1170 + 1171 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1172 + if err != nil { 1173 + fail("Failed to update labels, no record found on PDS.", err) 1174 + return 1175 + } 1176 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1177 + Collection: tangled.RepoNSID, 1178 + Repo: newRepo.Did, 1179 + Rkey: newRepo.Rkey, 1180 + SwapRecord: ex.Cid, 1181 + Record: &lexutil.LexiconTypeDecoder{ 1182 + Val: &repoRecord, 1183 + }, 1184 + }) 1185 + if err != nil { 1186 + fail("Failed to update repo record.", err) 1187 + return 1188 + } 1189 + 1190 + // transaction for DB changes 1191 + tx, err := rp.db.BeginTx(r.Context(), nil) 1192 + if err != nil { 1193 + fail("Failed to delete label.", err) 1194 + return 1195 + } 1196 + defer tx.Rollback() 1197 + 1198 + err = db.UnsubscribeLabel( 1199 + tx, 1200 + db.FilterEq("repo_at", f.RepoAt()), 1201 + db.FilterEq("label_at", removedAt), 1202 + ) 1203 + if err != nil { 1204 + fail("Failed to unsubscribe label.", err) 1205 + return 1206 + } 1207 + 1208 + err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id)) 1209 + if err != nil { 1210 + fail("Failed to delete label definition.", err) 1211 + return 1212 + } 1213 + 1214 + err = tx.Commit() 1215 + if err != nil { 1216 + fail("Failed to delete label.", err) 1217 + return 1218 + } 1219 + 1220 + // everything succeeded 1221 + rp.pages.HxRefresh(w) 1222 + } 1223 + 965 1224 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 966 1225 user := rp.oauth.GetUser(r) 967 1226 l := rp.logger.With("handler", "AddCollaborator") ··· 1589 1848 if errors.Is(err, sql.ErrNoRows) { 1590 1849 // no existing repo with this name found, we can use the name as is 1591 1850 } else { 1592 - log.Println("error fetching existing repo from db", err) 1851 + log.Println("error fetching existing repo from db", "err", err) 1593 1852 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1594 1853 return 1595 1854 }
+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
··· 258 258 259 259 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 260 260 logger := log.New("repo") 261 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 261 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator) 262 262 return repo.Router(mw) 263 263 } 264 264