forked from tangled.org/core
this repo has no description

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

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

oppi.li b420ced1 e24e2089

verified
Changed files
+368 -3
appview
pages
templates
repo
repo
state
+104
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
···
···
+
{{ define "repo/settings/fragments/addLabelDefModal" }}
+
<form
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
class="flex flex-col gap-4"
+
>
+
<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>
+
+
<div class="w-full">
+
<label for="name">Name</label>
+
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
+
</div>
+
+
<!-- Value Type -->
+
<div class="w-full">
+
<label for="valueType">Value Type</label>
+
<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">
+
<option value="string" selected>String</option>
+
<option value="integer">Integer</option>
+
<option value="boolean">Boolean</option>
+
<option value="null">None</option>
+
</select>
+
<details id="constrain-values" class="group">
+
<summary class="list-none cursor-pointer flex items-center gap-2 py-2">
+
<span class="group-open:hidden inline text-gray-500 dark:text-gray-400">{{ i "square-plus" "w-4 h-4" }}</span>
+
<span class="hidden group-open:inline text-gray-500 dark:text-gray-400">{{ i "square-minus" "w-4 h-4" }}</span>
+
<span>Constrain values</span>
+
</summary>
+
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
+
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Enter comma-separated list of permitted values.</p>
+
</details>
+
</div>
+
+
<!-- Scope -->
+
<div class="w-full">
+
<label for="scope">Scope</label>
+
<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">
+
<option value="sh.tangled.repo.issue">Issues</option>
+
<option value="sh.tangled.repo.pull">Pull Requests</option>
+
</select>
+
</div>
+
+
<!-- Color -->
+
<div class="w-full">
+
<label for="color">Color</label>
+
<div class="grid grid-cols-4 grid-rows-2 place-items-center">
+
{{ $colors := list "#ef4444" "#3b82f6" "#10b981" "#f59e0b" "#8b5cf6" "#ec4899" "#06b6d4" "#64748b" }}
+
{{ range $i, $color := $colors }}
+
<label class="relative">
+
<input type="radio" name="color" value="{{ $color }}" class="sr-only peer" {{ if eq $i 0 }} checked {{ end }}>
+
{{ template "repo/fragments/colorBall" (dict "color" $color "classes" "size-4 peer-checked:size-8 transition-all") }}
+
</label>
+
{{ end }}
+
</div>
+
</div>
+
+
<!-- Multiple -->
+
<div class="w-full flex flex-wrap gap-2">
+
<input type="checkbox" id="multiple" name="multiple" value="true" />
+
<span>
+
Allow multiple values
+
</span>
+
</div>
+
+
<div class="flex gap-2 pt-2">
+
<button
+
type="button"
+
popovertarget="add-labeldef-modal"
+
popovertargetaction="hide"
+
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"
+
>
+
{{ i "x" "size-4" }} cancel
+
</button>
+
<button type="submit" class="btn w-1/2 flex items-center">
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
+
</form>
+
+
<script>
+
document.getElementById('value-type').addEventListener('change', function() {
+
const constrainValues = document.getElementById('constrain-values');
+
const selectedValue = this.value;
+
+
if (selectedValue === 'string' || selectedValue === 'integer') {
+
constrainValues.classList.remove('hidden');
+
} else {
+
constrainValues.classList.add('hidden');
+
constrainValues.removeAttribute('open');
+
document.getElementById('enumValues').value = '';
+
}
+
});
+
+
function toggleDarkMode() {
+
document.documentElement.classList.toggle('dark');
+
}
+
</script>
+
{{ end }}
+
+1 -1
appview/pages/templates/repo/settings/pipelines.html
···
hx-swap="none"
class="flex flex-col gap-2"
>
-
<p class="uppercase p-0">ADD SECRET</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
<input
type="text"
···
hx-swap="none"
class="flex flex-col gap-2"
>
+
<p class="uppercase p-0 font-bold">ADD SECRET</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
<input
type="text"
+260 -1
appview/repo/repo.go
···
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/reporesolver"
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/idresolver"
···
notifier notify.Notifier
logger *slog.Logger
serviceAuth *serviceauth.ServiceAuth
}
func New(
···
notifier notify.Notifier,
enforcer *rbac.Enforcer,
logger *slog.Logger,
) *Repo {
return &Repo{oauth: oauth,
repoResolver: repoResolver,
···
notifier: notifier,
enforcer: enforcer,
logger: logger,
}
}
···
rp.pages.HxRefresh(w)
}
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
user := rp.oauth.GetUser(r)
l := rp.logger.With("handler", "AddCollaborator")
···
if errors.Is(err, sql.ErrNoRows) {
// no existing repo with this name found, we can use the name as is
} else {
-
log.Println("error fetching existing repo from db", err)
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
return
}
···
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/reporesolver"
+
"tangled.sh/tangled.sh/core/appview/validator"
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/idresolver"
···
notifier notify.Notifier
logger *slog.Logger
serviceAuth *serviceauth.ServiceAuth
+
validator *validator.Validator
}
func New(
···
notifier notify.Notifier,
enforcer *rbac.Enforcer,
logger *slog.Logger,
+
validator *validator.Validator,
) *Repo {
return &Repo{oauth: oauth,
repoResolver: repoResolver,
···
notifier: notifier,
enforcer: enforcer,
logger: logger,
+
validator: validator,
}
}
···
rp.pages.HxRefresh(w)
}
+
func (rp *Repo) AddLabel(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
l := rp.logger.With("handler", "AddLabel")
+
l = l.With("did", user.Did)
+
l = l.With("handle", user.Handle)
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
errorId := "add-label-error"
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
rp.pages.Notice(w, errorId, msg)
+
}
+
+
// get form values for label definition
+
name := r.FormValue("name")
+
concreteType := r.FormValue("valueType")
+
enumValues := r.FormValue("enumValues")
+
scope := r.FormValue("scope")
+
color := r.FormValue("color")
+
multiple := r.FormValue("multiple") == "true"
+
+
var variants []string
+
for part := range strings.SplitSeq(enumValues, ",") {
+
if part = strings.TrimSpace(part); part != "" {
+
variants = append(variants, part)
+
}
+
}
+
+
valueType := db.ValueType{
+
Type: db.ConcreteType(concreteType),
+
Format: db.ValueTypeFormatAny,
+
Enum: variants,
+
}
+
+
label := db.LabelDefinition{
+
Did: user.Did,
+
Rkey: tid.TID(),
+
Name: name,
+
ValueType: valueType,
+
Scope: syntax.NSID(scope),
+
Color: &color,
+
Multiple: multiple,
+
Created: time.Now(),
+
}
+
if err := rp.validator.ValidateLabelDefinition(&label); err != nil {
+
fail(err.Error(), err)
+
return
+
}
+
+
// announce this relation into the firehose, store into owners' pds
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
fail(err.Error(), err)
+
return
+
}
+
+
// emit a labelRecord
+
labelRecord := label.AsRecord()
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.LabelDefinitionNSID,
+
Repo: label.Did,
+
Rkey: label.Rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &labelRecord,
+
},
+
})
+
// invalid record
+
if err != nil {
+
fail("Failed to write record to PDS.", err)
+
return
+
}
+
+
aturi := resp.Uri
+
l = l.With("at-uri", aturi)
+
l.Info("wrote label record to PDS")
+
+
// update the repo to subscribe to this label
+
newRepo := f.Repo
+
newRepo.Labels = append(newRepo.Labels, aturi)
+
repoRecord := newRepo.AsRecord()
+
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
+
if err != nil {
+
fail("Failed to update labels, no record found on PDS.", err)
+
return
+
}
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoNSID,
+
Repo: newRepo.Did,
+
Rkey: newRepo.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &repoRecord,
+
},
+
})
+
+
tx, err := rp.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
fail("Failed to add label.", err)
+
return
+
}
+
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := rollbackRecord(context.Background(), aturi, client)
+
+
// ignore txn complete errors, this is okay
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
+
+
if errs := errors.Join(err1, err2); errs != nil {
+
l.Error("failed to rollback changes", "errs", errs)
+
return
+
}
+
}
+
defer rollback()
+
+
_, err = db.AddLabelDefinition(tx, &label)
+
if err != nil {
+
fail("Failed to add label.", err)
+
return
+
}
+
+
err = db.SubscribeLabel(tx, &db.RepoLabel{
+
RepoAt: f.RepoAt(),
+
LabelAt: label.AtUri(),
+
})
+
+
err = tx.Commit()
+
if err != nil {
+
fail("Failed to add label.", err)
+
return
+
}
+
+
// clear aturi when everything is successful
+
aturi = ""
+
+
rp.pages.HxRefresh(w)
+
}
+
+
func (rp *Repo) DeleteLabel(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
l := rp.logger.With("handler", "DeleteLabel")
+
l = l.With("did", user.Did)
+
l = l.With("handle", user.Handle)
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
errorId := "label-operation"
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
rp.pages.Notice(w, errorId, msg)
+
}
+
+
// get form values
+
labelId := r.FormValue("label-id")
+
+
label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId))
+
if err != nil {
+
fail("Failed to find label definition.", err)
+
return
+
}
+
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
fail(err.Error(), err)
+
return
+
}
+
+
// delete label record from PDS
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.LabelDefinitionNSID,
+
Repo: label.Did,
+
Rkey: label.Rkey,
+
})
+
if err != nil {
+
fail("Failed to delete label record from PDS.", err)
+
return
+
}
+
+
// update repo record to remove the label reference
+
newRepo := f.Repo
+
var updated []string
+
removedAt := label.AtUri().String()
+
for _, l := range newRepo.Labels {
+
if l != removedAt {
+
updated = append(updated, l)
+
}
+
}
+
newRepo.Labels = updated
+
repoRecord := newRepo.AsRecord()
+
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
+
if err != nil {
+
fail("Failed to update labels, no record found on PDS.", err)
+
return
+
}
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoNSID,
+
Repo: newRepo.Did,
+
Rkey: newRepo.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &repoRecord,
+
},
+
})
+
if err != nil {
+
fail("Failed to update repo record.", err)
+
return
+
}
+
+
// transaction for DB changes
+
tx, err := rp.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
fail("Failed to delete label.", err)
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.UnsubscribeLabel(
+
tx,
+
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterEq("label_at", removedAt),
+
)
+
if err != nil {
+
fail("Failed to unsubscribe label.", err)
+
return
+
}
+
+
err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id))
+
if err != nil {
+
fail("Failed to delete label definition.", err)
+
return
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
fail("Failed to delete label.", err)
+
return
+
}
+
+
// everything succeeded
+
rp.pages.HxRefresh(w)
+
}
+
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
user := rp.oauth.GetUser(r)
l := rp.logger.With("handler", "AddCollaborator")
···
if errors.Is(err, sql.ErrNoRows) {
// no existing repo with this name found, we can use the name as is
} else {
+
log.Println("error fetching existing repo from db", "err", err)
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
return
}
+2
appview/repo/router.go
···
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
r.Get("/", rp.RepoSettings)
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
r.Put("/branches/default", rp.SetDefaultBranch)
···
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
r.Get("/", rp.RepoSettings)
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabel)
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabel)
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
r.Put("/branches/default", rp.SetDefaultBranch)
+1 -1
appview/state/router.go
···
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
logger := log.New("repo")
-
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger)
return repo.Router(mw)
}
···
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
logger := log.New("repo")
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator)
return repo.Router(mw)
}