+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.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
+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
···
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