+104
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
+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
+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
+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
+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
+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