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

Compare changes

Choose any two refs to compare.

Changed files
+8557 -7003
.air
.tangled
workflows
.zed
api
appview
cache
session
config
db
idresolver
issues
knots
models
notifications
notify
oauth
pages
markup
repoinfo
templates
brand
errors
fragments
knots
labels
layouts
notifications
fragments
repo
spindles
strings
timeline
user
pagination
pipelines
pulls
repo
serververify
signup
spindleverify
state
strings
validator
avatar
src
camo
cmd
combinediff
interdiff
keyfetch
knot
knotserver
punchcardPopulate
repoguard
consts
contrib
docker
rootfs
etc
s6-overlay
s6-rc.d
create-sshd-host-keys
knotserver
dependencies.d
sshd
user
contents.d
scripts
docs
eventconsumer
hook
idresolver
keyfetch
knotclient
knotserver
legal
lexicons
log
nix
notifier
patchutil
rbac
spindle
systemd
tid
types
workflow
xrpc
errors
-21
appview/pages/templates/index.html
···
-
<html>
-
{{ template "layouts/head" . }}
-
-
<header>
-
<h1>{{ .meta.Title }}</h1>
-
<h2>{{ .meta.Description }}</h2>
-
</header>
-
<body>
-
<main>
-
<div class="index">
-
{{ range .info }}
-
<div class="index-name">
-
<a href="/{{ .Name }}">{{ .DisplayName }}</a>
-
</div>
-
<div class="desc">{{ .Desc }}</div>
-
<div>{{ .Idle }}</div>
-
{{ end }}
-
</div>
-
</main>
-
</body>
-
</html>
···
+6
appview/pages/htmx.go
···
w.Write([]byte(html))
}
// HxRedirect is a full page reload with a new location.
func (s *Pages) HxRedirect(w http.ResponseWriter, location string) {
w.Header().Set("HX-Redirect", location)
···
w.Write([]byte(html))
}
+
// HxRefresh is a client-side full refresh of the page.
+
func (s *Pages) HxRefresh(w http.ResponseWriter) {
+
w.Header().Set("HX-Refresh", "true")
+
w.WriteHeader(http.StatusOK)
+
}
+
// HxRedirect is a full page reload with a new location.
func (s *Pages) HxRedirect(w http.ResponseWriter, location string) {
w.Header().Set("HX-Redirect", location)
+2 -1
camo/src/mimetypes.json
···
"image/x-rgb",
"image/x-xbitmap",
"image/x-xpixmap",
-
"image/x-xwindowdump"
]
···
"image/x-rgb",
"image/x-xbitmap",
"image/x-xpixmap",
+
"image/x-xwindowdump",
+
"video/mp4"
]
+2 -1
license
···
MIT License
-
Copyright (c) 2025 Anirudh Oppiliappan, Akshay Oppiliappan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
···
MIT License
+
Copyright (c) 2025 Anirudh Oppiliappan, Akshay Oppiliappan and
+
contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1
types/tree.go
···
// A nicer git tree representation.
type NiceTree struct {
Name string `json:"name"`
Mode string `json:"mode"`
Size int64 `json:"size"`
···
// A nicer git tree representation.
type NiceTree struct {
+
// Relative path
Name string `json:"name"`
Mode string `json:"mode"`
Size int64 `json:"size"`
+20
types/patch.go
···
···
+
package types
+
+
import (
+
"fmt"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
)
+
+
type FormatPatch struct {
+
Files []*gitdiff.File
+
*gitdiff.PatchHeader
+
Raw string
+
}
+
+
func (f FormatPatch) ChangeId() (string, error) {
+
if vals, ok := f.RawHeaders["Change-Id"]; ok && len(vals) == 1 {
+
return vals[0], nil
+
}
+
return "", fmt.Errorf("no change-id found")
+
}
+45 -1
appview/resolver.go
···
import (
"context"
"sync"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
type Resolver struct {
directory identity.Directory
}
-
func NewResolver() *Resolver {
return &Resolver{
directory: identity.DefaultDirectory(),
}
}
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
id, err := syntax.ParseAtIdentifier(arg)
if err != nil {
···
import (
"context"
+
"net"
+
"net/http"
"sync"
+
"time"
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/carlmjohnson/versioninfo"
)
type Resolver struct {
directory identity.Directory
}
+
func BaseDirectory() identity.Directory {
+
base := identity.BaseDirectory{
+
PLCURL: identity.DefaultPLCURL,
+
HTTPClient: http.Client{
+
Timeout: time.Second * 10,
+
Transport: &http.Transport{
+
// would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad.
+
IdleConnTimeout: time.Millisecond * 1000,
+
MaxIdleConns: 100,
+
},
+
},
+
Resolver: net.Resolver{
+
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+
d := net.Dialer{Timeout: time.Second * 3}
+
return d.DialContext(ctx, network, address)
+
},
+
},
+
TryAuthoritativeDNS: true,
+
// primary Bluesky PDS instance only supports HTTP resolution method
+
SkipDNSDomainSuffixes: []string{".bsky.social"},
+
UserAgent: "indigo-identity/" + versioninfo.Short(),
+
}
+
return &base
+
}
+
+
func RedisDirectory(url string) (identity.Directory, error) {
+
return redisdir.NewRedisDirectory(BaseDirectory(), url, time.Hour*24, time.Hour*1, time.Hour*1, 10000)
+
}
+
+
func DefaultResolver() *Resolver {
return &Resolver{
directory: identity.DefaultDirectory(),
}
}
+
func RedisResolver(config RedisConfig) (*Resolver, error) {
+
directory, err := RedisDirectory(config.ToURL())
+
if err != nil {
+
return nil, err
+
}
+
return &Resolver{
+
directory: directory,
+
}, nil
+
}
+
func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) {
id, err := syntax.ParseAtIdentifier(arg)
if err != nil {
+6 -5
appview/state/artifact.go
···
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/knotclient"
"tangled.sh/tangled.sh/core/types"
)
···
func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
tagParam := chi.URLParam(r, "tag")
-
f, err := s.fullyResolvedRepo(r)
if err != nil {
log.Println("failed to get repo and knot", err)
s.pages.Notice(w, "upload", "failed to upload artifact, error in repo resolution")
···
s.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(s, user),
Artifact: artifact,
})
}
···
func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
tagParam := chi.URLParam(r, "tag")
filename := chi.URLParam(r, "file")
-
f, err := s.fullyResolvedRepo(r)
if err != nil {
log.Println("failed to get repo and knot", err)
return
···
user := s.oauth.GetUser(r)
tagParam := chi.URLParam(r, "tag")
filename := chi.URLParam(r, "file")
-
f, err := s.fullyResolvedRepo(r)
if err != nil {
log.Println("failed to get repo and knot", err)
return
···
w.Write([]byte{})
}
-
func (s *State) resolveTag(f *FullyResolvedRepo, tagParam string) (*types.TagReference, error) {
tagParam, err := url.QueryUnescape(tagParam)
if err != nil {
return nil, err
···
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/appview/reporesolver"
"tangled.sh/tangled.sh/core/knotclient"
"tangled.sh/tangled.sh/core/types"
)
···
func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
tagParam := chi.URLParam(r, "tag")
+
f, err := s.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
s.pages.Notice(w, "upload", "failed to upload artifact, error in repo resolution")
···
s.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
Artifact: artifact,
})
}
···
func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
tagParam := chi.URLParam(r, "tag")
filename := chi.URLParam(r, "file")
+
f, err := s.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
return
···
user := s.oauth.GetUser(r)
tagParam := chi.URLParam(r, "tag")
filename := chi.URLParam(r, "file")
+
f, err := s.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
return
···
w.Write([]byte{})
}
+
func (s *State) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
tagParam, err := url.QueryUnescape(tagParam)
if err != nil {
return nil, err
-2093
appview/state/pull.go
···
-
package state
-
-
import (
-
"database/sql"
-
"encoding/json"
-
"errors"
-
"fmt"
-
"io"
-
"log"
-
"net/http"
-
"sort"
-
"strconv"
-
"strings"
-
"time"
-
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/appview"
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/oauth"
-
"tangled.sh/tangled.sh/core/appview/pages"
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
-
"tangled.sh/tangled.sh/core/knotclient"
-
"tangled.sh/tangled.sh/core/patchutil"
-
"tangled.sh/tangled.sh/core/types"
-
-
"github.com/bluekeyes/go-gitdiff/gitdiff"
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
-
"github.com/go-chi/chi/v5"
-
"github.com/google/uuid"
-
"github.com/posthog/posthog-go"
-
)
-
-
// htmx fragment
-
func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
-
switch r.Method {
-
case http.MethodGet:
-
user := s.oauth.GetUser(r)
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// can be nil if this pull is not stacked
-
stack, _ := r.Context().Value("stack").(db.Stack)
-
-
roundNumberStr := chi.URLParam(r, "round")
-
roundNumber, err := strconv.Atoi(roundNumberStr)
-
if err != nil {
-
roundNumber = pull.LastRoundNumber()
-
}
-
if roundNumber >= len(pull.Submissions) {
-
http.Error(w, "bad round id", http.StatusBadRequest)
-
log.Println("failed to parse round id", err)
-
return
-
}
-
-
mergeCheckResponse := s.mergeCheck(f, pull, stack)
-
resubmitResult := pages.Unknown
-
if user.Did == pull.OwnerDid {
-
resubmitResult = s.resubmitCheck(f, pull, stack)
-
}
-
-
s.pages.PullActionsFragment(w, pages.PullActionsParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Pull: pull,
-
RoundNumber: roundNumber,
-
MergeCheck: mergeCheckResponse,
-
ResubmitCheck: resubmitResult,
-
Stack: stack,
-
})
-
return
-
}
-
}
-
-
func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// can be nil if this pull is not stacked
-
stack, _ := r.Context().Value("stack").(db.Stack)
-
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull)
-
-
totalIdents := 1
-
for _, submission := range pull.Submissions {
-
totalIdents += len(submission.Comments)
-
}
-
-
identsToResolve := make([]string, totalIdents)
-
-
// populate idents
-
identsToResolve[0] = pull.OwnerDid
-
idx := 1
-
for _, submission := range pull.Submissions {
-
for _, comment := range submission.Comments {
-
identsToResolve[idx] = comment.OwnerDid
-
idx += 1
-
}
-
}
-
-
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
-
mergeCheckResponse := s.mergeCheck(f, pull, stack)
-
resubmitResult := pages.Unknown
-
if user != nil && user.Did == pull.OwnerDid {
-
resubmitResult = s.resubmitCheck(f, pull, stack)
-
}
-
-
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
DidHandleMap: didHandleMap,
-
Pull: pull,
-
Stack: stack,
-
AbandonedPulls: abandonedPulls,
-
MergeCheck: mergeCheckResponse,
-
ResubmitCheck: resubmitResult,
-
})
-
}
-
-
func (s *State) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
-
if pull.State == db.PullMerged {
-
return types.MergeCheckResponse{}
-
}
-
-
secret, err := db.GetRegistrationKey(s.db, f.Knot)
-
if err != nil {
-
log.Printf("failed to get registration key: %v", err)
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: this knot is unregistered",
-
}
-
}
-
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status",
-
}
-
}
-
-
patch := pull.LatestPatch()
-
if pull.IsStacked() {
-
// combine patches of substack
-
subStack := stack.Below(pull)
-
// collect the portion of the stack that is mergeable
-
mergeable := subStack.Mergeable()
-
// combine each patch
-
patch = mergeable.CombinedPatch()
-
}
-
-
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
-
if err != nil {
-
log.Println("failed to check for mergeability:", err)
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status",
-
}
-
}
-
switch resp.StatusCode {
-
case 404:
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: this knot does not support PRs",
-
}
-
case 400:
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: does this knot support PRs?",
-
}
-
}
-
-
respBody, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Println("failed to read merge check response body")
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: knot is not speaking the right language",
-
}
-
}
-
defer resp.Body.Close()
-
-
var mergeCheckResponse types.MergeCheckResponse
-
err = json.Unmarshal(respBody, &mergeCheckResponse)
-
if err != nil {
-
log.Println("failed to unmarshal merge check response", err)
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: knot is not speaking the right language",
-
}
-
}
-
-
return mergeCheckResponse
-
}
-
-
func (s *State) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
-
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
-
return pages.Unknown
-
}
-
-
var knot, ownerDid, repoName string
-
-
if pull.PullSource.RepoAt != nil {
-
// fork-based pulls
-
sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
-
if err != nil {
-
log.Println("failed to get source repo", err)
-
return pages.Unknown
-
}
-
-
knot = sourceRepo.Knot
-
ownerDid = sourceRepo.Did
-
repoName = sourceRepo.Name
-
} else {
-
// pulls within the same repo
-
knot = f.Knot
-
ownerDid = f.OwnerDid()
-
repoName = f.RepoName
-
}
-
-
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
-
return pages.Unknown
-
}
-
-
result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
-
return pages.Unknown
-
}
-
-
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
-
-
if pull.IsStacked() && stack != nil {
-
top := stack[0]
-
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
-
}
-
-
log.Println(latestSourceRev, result.Branch.Hash)
-
-
if latestSourceRev != result.Branch.Hash {
-
return pages.ShouldResubmit
-
}
-
-
return pages.ShouldNotResubmit
-
}
-
-
func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
stack, _ := r.Context().Value("stack").(db.Stack)
-
-
roundId := chi.URLParam(r, "round")
-
roundIdInt, err := strconv.Atoi(roundId)
-
if err != nil || roundIdInt >= len(pull.Submissions) {
-
http.Error(w, "bad round id", http.StatusBadRequest)
-
log.Println("failed to parse round id", err)
-
return
-
}
-
-
identsToResolve := []string{pull.OwnerDid}
-
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
-
patch := pull.Submissions[roundIdInt].Patch
-
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
-
-
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
-
LoggedInUser: user,
-
DidHandleMap: didHandleMap,
-
RepoInfo: f.RepoInfo(user),
-
Pull: pull,
-
Stack: stack,
-
Round: roundIdInt,
-
Submission: pull.Submissions[roundIdInt],
-
Diff: &diff,
-
})
-
-
}
-
-
func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to get pull.")
-
return
-
}
-
-
roundId := chi.URLParam(r, "round")
-
roundIdInt, err := strconv.Atoi(roundId)
-
if err != nil || roundIdInt >= len(pull.Submissions) {
-
http.Error(w, "bad round id", http.StatusBadRequest)
-
log.Println("failed to parse round id", err)
-
return
-
}
-
-
if roundIdInt == 0 {
-
http.Error(w, "bad round id", http.StatusBadRequest)
-
log.Println("cannot interdiff initial submission")
-
return
-
}
-
-
identsToResolve := []string{pull.OwnerDid}
-
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
-
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
-
if err != nil {
-
log.Println("failed to interdiff; current patch malformed")
-
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
-
return
-
}
-
-
previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch)
-
if err != nil {
-
log.Println("failed to interdiff; previous patch malformed")
-
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
-
return
-
}
-
-
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
-
-
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
-
LoggedInUser: s.oauth.GetUser(r),
-
RepoInfo: f.RepoInfo(user),
-
Pull: pull,
-
Round: roundIdInt,
-
DidHandleMap: didHandleMap,
-
Interdiff: interdiff,
-
})
-
return
-
}
-
-
func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
roundId := chi.URLParam(r, "round")
-
roundIdInt, err := strconv.Atoi(roundId)
-
if err != nil || roundIdInt >= len(pull.Submissions) {
-
http.Error(w, "bad round id", http.StatusBadRequest)
-
log.Println("failed to parse round id", err)
-
return
-
}
-
-
identsToResolve := []string{pull.OwnerDid}
-
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
-
w.Header().Set("Content-Type", "text/plain")
-
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
-
}
-
-
func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
params := r.URL.Query()
-
-
state := db.PullOpen
-
switch params.Get("state") {
-
case "closed":
-
state = db.PullClosed
-
case "merged":
-
state = db.PullMerged
-
}
-
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
pulls, err := db.GetPulls(
-
s.db,
-
db.FilterEq("repo_at", f.RepoAt),
-
db.FilterEq("state", state),
-
)
-
if err != nil {
-
log.Println("failed to get pulls", err)
-
s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
-
return
-
}
-
-
for _, p := range pulls {
-
var pullSourceRepo *db.Repo
-
if p.PullSource != nil {
-
if p.PullSource.RepoAt != nil {
-
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
-
if err != nil {
-
log.Printf("failed to get repo by at uri: %v", err)
-
continue
-
} else {
-
p.PullSource.Repo = pullSourceRepo
-
}
-
}
-
}
-
}
-
-
identsToResolve := make([]string, len(pulls))
-
for i, pull := range pulls {
-
identsToResolve[i] = pull.OwnerDid
-
}
-
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
-
s.pages.RepoPulls(w, pages.RepoPullsParams{
-
LoggedInUser: s.oauth.GetUser(r),
-
RepoInfo: f.RepoInfo(user),
-
Pulls: pulls,
-
DidHandleMap: didHandleMap,
-
FilteringBy: state,
-
})
-
return
-
}
-
-
func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
roundNumberStr := chi.URLParam(r, "round")
-
roundNumber, err := strconv.Atoi(roundNumberStr)
-
if err != nil || roundNumber >= len(pull.Submissions) {
-
http.Error(w, "bad round id", http.StatusBadRequest)
-
log.Println("failed to parse round id", err)
-
return
-
}
-
-
switch r.Method {
-
case http.MethodGet:
-
s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Pull: pull,
-
RoundNumber: roundNumber,
-
})
-
return
-
case http.MethodPost:
-
body := r.FormValue("body")
-
if body == "" {
-
s.pages.Notice(w, "pull", "Comment body is required")
-
return
-
}
-
-
// Start a transaction
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start transaction", err)
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
-
return
-
}
-
defer tx.Rollback()
-
-
createdAt := time.Now().Format(time.RFC3339)
-
ownerDid := user.Did
-
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
-
if err != nil {
-
log.Println("failed to get pull at", err)
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
-
return
-
}
-
-
atUri := f.RepoAt.String()
-
client, err := s.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
-
return
-
}
-
atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoPullCommentNSID,
-
Repo: user.Did,
-
Rkey: appview.TID(),
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoPullComment{
-
Repo: &atUri,
-
Pull: string(pullAt),
-
Owner: &ownerDid,
-
Body: body,
-
CreatedAt: createdAt,
-
},
-
},
-
})
-
if err != nil {
-
log.Println("failed to create pull comment", err)
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
-
return
-
}
-
-
// Create the pull comment in the database with the commentAt field
-
commentId, err := db.NewPullComment(tx, &db.PullComment{
-
OwnerDid: user.Did,
-
RepoAt: f.RepoAt.String(),
-
PullId: pull.PullId,
-
Body: body,
-
CommentAt: atResp.Uri,
-
SubmissionId: pull.Submissions[roundNumber].ID,
-
})
-
if err != nil {
-
log.Println("failed to create pull comment", err)
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
-
return
-
}
-
-
// Commit the transaction
-
if err = tx.Commit(); err != nil {
-
log.Println("failed to commit transaction", err)
-
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
-
return
-
}
-
-
if !s.config.Core.Dev {
-
err = s.posthog.Enqueue(posthog.Capture{
-
DistinctId: user.Did,
-
Event: "new_pull_comment",
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
-
return
-
}
-
}
-
-
func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
switch r.Method {
-
case http.MethodGet:
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
s.pages.Error503(w)
-
return
-
}
-
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
-
if err != nil {
-
log.Println("failed to fetch branches", err)
-
return
-
}
-
-
// can be one of "patch", "branch" or "fork"
-
strategy := r.URL.Query().Get("strategy")
-
// ignored if strategy is "patch"
-
sourceBranch := r.URL.Query().Get("sourceBranch")
-
targetBranch := r.URL.Query().Get("targetBranch")
-
-
s.pages.RepoNewPull(w, pages.RepoNewPullParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Branches: result.Branches,
-
Strategy: strategy,
-
SourceBranch: sourceBranch,
-
TargetBranch: targetBranch,
-
Title: r.URL.Query().Get("title"),
-
Body: r.URL.Query().Get("body"),
-
})
-
-
case http.MethodPost:
-
title := r.FormValue("title")
-
body := r.FormValue("body")
-
targetBranch := r.FormValue("targetBranch")
-
fromFork := r.FormValue("fork")
-
sourceBranch := r.FormValue("sourceBranch")
-
patch := r.FormValue("patch")
-
-
if targetBranch == "" {
-
s.pages.Notice(w, "pull", "Target branch is required.")
-
return
-
}
-
-
// Determine PR type based on input parameters
-
isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed()
-
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
-
isForkBased := fromFork != "" && sourceBranch != ""
-
isPatchBased := patch != "" && !isBranchBased && !isForkBased
-
isStacked := r.FormValue("isStacked") == "on"
-
-
if isPatchBased && !patchutil.IsFormatPatch(patch) {
-
if title == "" {
-
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
-
return
-
}
-
}
-
-
// Validate we have at least one valid PR creation method
-
if !isBranchBased && !isPatchBased && !isForkBased {
-
s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
-
return
-
}
-
-
// Can't mix branch-based and patch-based approaches
-
if isBranchBased && patch != "" {
-
s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
-
return
-
}
-
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
-
return
-
}
-
-
caps, err := us.Capabilities()
-
if err != nil {
-
log.Println("error fetching knot caps", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
-
return
-
}
-
-
if !caps.PullRequests.FormatPatch {
-
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
-
return
-
}
-
-
// Handle the PR creation based on the type
-
if isBranchBased {
-
if !caps.PullRequests.BranchSubmissions {
-
s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
-
return
-
}
-
s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked)
-
} else if isForkBased {
-
if !caps.PullRequests.ForkSubmissions {
-
s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
-
return
-
}
-
s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked)
-
} else if isPatchBased {
-
if !caps.PullRequests.PatchSubmissions {
-
s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
-
return
-
}
-
s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked)
-
}
-
return
-
}
-
}
-
-
func (s *State) handleBranchBasedPull(
-
w http.ResponseWriter,
-
r *http.Request,
-
f *reporesolver.ResolvedRepo,
-
user *oauth.User,
-
title,
-
body,
-
targetBranch,
-
sourceBranch string,
-
isStacked bool,
-
) {
-
pullSource := &db.PullSource{
-
Branch: sourceBranch,
-
}
-
recordPullSource := &tangled.RepoPull_Source{
-
Branch: sourceBranch,
-
}
-
-
// Generate a patch using /compare
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
-
if err != nil {
-
log.Println("failed to compare", err)
-
s.pages.Notice(w, "pull", err.Error())
-
return
-
}
-
-
sourceRev := comparison.Rev2
-
patch := comparison.Patch
-
-
if !patchutil.IsPatchValid(patch) {
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
-
return
-
}
-
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
-
}
-
-
func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
-
if !patchutil.IsPatchValid(patch) {
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
-
return
-
}
-
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
-
}
-
-
func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
-
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
-
if errors.Is(err, sql.ErrNoRows) {
-
s.pages.Notice(w, "pull", "No such fork.")
-
return
-
} else if err != nil {
-
log.Println("failed to fetch fork:", err)
-
s.pages.Notice(w, "pull", "Failed to fetch fork.")
-
return
-
}
-
-
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
-
if err != nil {
-
log.Println("failed to fetch registration key:", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create signed client:", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create unsigned client:", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
-
if err != nil {
-
log.Println("failed to create hidden ref:", err, resp.StatusCode)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
switch resp.StatusCode {
-
case 404:
-
case 400:
-
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
-
return
-
}
-
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
-
// We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
-
// the targetBranch on the target repository. This code is a bit confusing, but here's an example:
-
// hiddenRef: hidden/feature-1/main (on repo-fork)
-
// targetBranch: main (on repo-1)
-
// sourceBranch: feature-1 (on repo-fork)
-
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
-
if err != nil {
-
log.Println("failed to compare across branches", err)
-
s.pages.Notice(w, "pull", err.Error())
-
return
-
}
-
-
sourceRev := comparison.Rev2
-
patch := comparison.Patch
-
-
if !patchutil.IsPatchValid(patch) {
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
-
return
-
}
-
-
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
-
if err != nil {
-
log.Println("failed to parse fork AT URI", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
-
Branch: sourceBranch,
-
RepoAt: &forkAtUri,
-
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked)
-
}
-
-
func (s *State) createPullRequest(
-
w http.ResponseWriter,
-
r *http.Request,
-
f *reporesolver.ResolvedRepo,
-
user *oauth.User,
-
title, body, targetBranch string,
-
patch string,
-
sourceRev string,
-
pullSource *db.PullSource,
-
recordPullSource *tangled.RepoPull_Source,
-
isStacked bool,
-
) {
-
if isStacked {
-
// creates a series of PRs, each linking to the previous, identified by jj's change-id
-
s.createStackedPulLRequest(
-
w,
-
r,
-
f,
-
user,
-
targetBranch,
-
patch,
-
sourceRev,
-
pullSource,
-
)
-
return
-
}
-
-
client, err := s.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start tx")
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
// We've already checked earlier if it's diff-based and title is empty,
-
// so if it's still empty now, it's intentionally skipped owing to format-patch.
-
if title == "" {
-
formatPatches, err := patchutil.ExtractPatches(patch)
-
if err != nil {
-
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
-
return
-
}
-
if len(formatPatches) == 0 {
-
s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
-
return
-
}
-
-
title = formatPatches[0].Title
-
body = formatPatches[0].Body
-
}
-
-
rkey := appview.TID()
-
initialSubmission := db.PullSubmission{
-
Patch: patch,
-
SourceRev: sourceRev,
-
}
-
err = db.NewPull(tx, &db.Pull{
-
Title: title,
-
Body: body,
-
TargetBranch: targetBranch,
-
OwnerDid: user.Did,
-
RepoAt: f.RepoAt,
-
Rkey: rkey,
-
Submissions: []*db.PullSubmission{
-
&initialSubmission,
-
},
-
PullSource: pullSource,
-
})
-
if err != nil {
-
log.Println("failed to create pull request", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
pullId, err := db.NextPullId(tx, f.RepoAt)
-
if err != nil {
-
log.Println("failed to get pull id", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoPullNSID,
-
Repo: user.Did,
-
Rkey: rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoPull{
-
Title: title,
-
PullId: int64(pullId),
-
TargetRepo: string(f.RepoAt),
-
TargetBranch: targetBranch,
-
Patch: patch,
-
Source: recordPullSource,
-
},
-
},
-
})
-
if err != nil {
-
log.Println("failed to create pull request", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
if err = tx.Commit(); err != nil {
-
log.Println("failed to create pull request", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
if !s.config.Core.Dev {
-
err = s.posthog.Enqueue(posthog.Capture{
-
DistinctId: user.Did,
-
Event: "new_pull",
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
-
}
-
-
func (s *State) createStackedPulLRequest(
-
w http.ResponseWriter,
-
r *http.Request,
-
f *reporesolver.ResolvedRepo,
-
user *oauth.User,
-
targetBranch string,
-
patch string,
-
sourceRev string,
-
pullSource *db.PullSource,
-
) {
-
// run some necessary checks for stacked-prs first
-
-
// must be branch or fork based
-
if sourceRev == "" {
-
log.Println("stacked PR from patch-based pull")
-
s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.")
-
return
-
}
-
-
formatPatches, err := patchutil.ExtractPatches(patch)
-
if err != nil {
-
log.Println("failed to extract patches", err)
-
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
-
return
-
}
-
-
// must have atleast 1 patch to begin with
-
if len(formatPatches) == 0 {
-
log.Println("empty patches")
-
s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
-
return
-
}
-
-
// build a stack out of this patch
-
stackId := uuid.New()
-
stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
-
if err != nil {
-
log.Println("failed to create stack", err)
-
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
-
return
-
}
-
-
client, err := s.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
// apply all record creations at once
-
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
-
for _, p := range stack {
-
record := p.AsRecord()
-
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
-
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
-
Collection: tangled.RepoPullNSID,
-
Rkey: &p.Rkey,
-
Value: &lexutil.LexiconTypeDecoder{
-
Val: &record,
-
},
-
},
-
}
-
writes = append(writes, &write)
-
}
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
-
Repo: user.Did,
-
Writes: writes,
-
})
-
if err != nil {
-
log.Println("failed to create stacked pull request", err)
-
s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
-
return
-
}
-
-
// create all pulls at once
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start tx")
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
for _, p := range stack {
-
err = db.NewPull(tx, p)
-
if err != nil {
-
log.Println("failed to create pull request", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
}
-
-
if err = tx.Commit(); err != nil {
-
log.Println("failed to create pull request", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
-
}
-
-
func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
-
_, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
patch := r.FormValue("patch")
-
if patch == "" {
-
s.pages.Notice(w, "patch-error", "Patch is required.")
-
return
-
}
-
-
if patch == "" || !patchutil.IsPatchValid(patch) {
-
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
-
return
-
}
-
-
if patchutil.IsFormatPatch(patch) {
-
s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
-
} else {
-
s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
-
}
-
}
-
-
func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
-
RepoInfo: f.RepoInfo(user),
-
})
-
}
-
-
func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
s.pages.Error503(w)
-
return
-
}
-
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
-
return
-
}
-
-
branches := result.Branches
-
sort.Slice(branches, func(i int, j int) bool {
-
return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
-
})
-
-
withoutDefault := []types.Branch{}
-
for _, b := range branches {
-
if b.IsDefault {
-
continue
-
}
-
withoutDefault = append(withoutDefault, b)
-
}
-
-
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
-
RepoInfo: f.RepoInfo(user),
-
Branches: withoutDefault,
-
})
-
}
-
-
func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
forks, err := db.GetForksByDid(s.db, user.Did)
-
if err != nil {
-
log.Println("failed to get forks", err)
-
return
-
}
-
-
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
-
RepoInfo: f.RepoInfo(user),
-
Forks: forks,
-
Selected: r.URL.Query().Get("fork"),
-
})
-
}
-
-
func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
forkVal := r.URL.Query().Get("fork")
-
-
// fork repo
-
repo, err := db.GetRepo(s.db, user.Did, forkVal)
-
if err != nil {
-
log.Println("failed to get repo", user.Did, forkVal)
-
return
-
}
-
-
sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s", repo.Knot)
-
s.pages.Error503(w)
-
return
-
}
-
-
sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
-
if err != nil {
-
log.Println("failed to reach knotserver for source branches", err)
-
return
-
}
-
-
targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
-
s.pages.Error503(w)
-
return
-
}
-
-
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
-
if err != nil {
-
log.Println("failed to reach knotserver for target branches", err)
-
return
-
}
-
-
sourceBranches := sourceResult.Branches
-
sort.Slice(sourceBranches, func(i int, j int) bool {
-
return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
-
})
-
-
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
-
RepoInfo: f.RepoInfo(user),
-
SourceBranches: sourceBranches,
-
TargetBranches: targetResult.Branches,
-
})
-
}
-
-
func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
switch r.Method {
-
case http.MethodGet:
-
s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
-
RepoInfo: f.RepoInfo(user),
-
Pull: pull,
-
})
-
return
-
case http.MethodPost:
-
if pull.IsPatchBased() {
-
s.resubmitPatch(w, r)
-
return
-
} else if pull.IsBranchBased() {
-
s.resubmitBranch(w, r)
-
return
-
} else if pull.IsForkBased() {
-
s.resubmitFork(w, r)
-
return
-
}
-
}
-
}
-
-
func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
if user.Did != pull.OwnerDid {
-
log.Println("unauthorized user")
-
w.WriteHeader(http.StatusUnauthorized)
-
return
-
}
-
-
patch := r.FormValue("patch")
-
-
s.resubmitPullHelper(w, r, f, user, pull, patch, "")
-
}
-
-
func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
if user.Did != pull.OwnerDid {
-
log.Println("unauthorized user")
-
w.WriteHeader(http.StatusUnauthorized)
-
return
-
}
-
-
if !f.RepoInfo(user).Roles.IsPushAllowed() {
-
log.Println("unauthorized user")
-
w.WriteHeader(http.StatusUnauthorized)
-
return
-
}
-
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create client for %s: %s", f.Knot, err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
-
if err != nil {
-
log.Printf("compare request failed: %s", err)
-
s.pages.Notice(w, "resubmit-error", err.Error())
-
return
-
}
-
-
sourceRev := comparison.Rev2
-
patch := comparison.Patch
-
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
-
}
-
-
func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
if user.Did != pull.OwnerDid {
-
log.Println("unauthorized user")
-
w.WriteHeader(http.StatusUnauthorized)
-
return
-
}
-
-
forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
-
if err != nil {
-
log.Println("failed to get source repo", err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
// extract patch by performing compare
-
ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
-
if err != nil {
-
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
// update the hidden tracking branch to latest
-
signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
-
if err != nil || resp.StatusCode != http.StatusNoContent {
-
log.Printf("failed to update tracking branch: %s", err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
-
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
-
if err != nil {
-
log.Printf("failed to compare branches: %s", err)
-
s.pages.Notice(w, "resubmit-error", err.Error())
-
return
-
}
-
-
sourceRev := comparison.Rev2
-
patch := comparison.Patch
-
-
s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
-
}
-
-
// validate a resubmission against a pull request
-
func validateResubmittedPatch(pull *db.Pull, patch string) error {
-
if patch == "" {
-
return fmt.Errorf("Patch is empty.")
-
}
-
-
if patch == pull.LatestPatch() {
-
return fmt.Errorf("Patch is identical to previous submission.")
-
}
-
-
if !patchutil.IsPatchValid(patch) {
-
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
-
}
-
-
return nil
-
}
-
-
func (s *State) resubmitPullHelper(
-
w http.ResponseWriter,
-
r *http.Request,
-
f *reporesolver.ResolvedRepo,
-
user *oauth.User,
-
pull *db.Pull,
-
patch string,
-
sourceRev string,
-
) {
-
if pull.IsStacked() {
-
log.Println("resubmitting stacked PR")
-
s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
-
return
-
}
-
-
if err := validateResubmittedPatch(pull, patch); err != nil {
-
s.pages.Notice(w, "resubmit-error", err.Error())
-
return
-
}
-
-
// validate sourceRev if branch/fork based
-
if pull.IsBranchBased() || pull.IsForkBased() {
-
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
-
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
-
return
-
}
-
}
-
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start tx")
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
err = db.ResubmitPull(tx, pull, patch, sourceRev)
-
if err != nil {
-
log.Println("failed to create pull request", err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
client, err := s.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to authorize client")
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
-
if err != nil {
-
// failed to get record
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
-
return
-
}
-
-
var recordPullSource *tangled.RepoPull_Source
-
if pull.IsBranchBased() {
-
recordPullSource = &tangled.RepoPull_Source{
-
Branch: pull.PullSource.Branch,
-
}
-
}
-
if pull.IsForkBased() {
-
repoAt := pull.PullSource.RepoAt.String()
-
recordPullSource = &tangled.RepoPull_Source{
-
Branch: pull.PullSource.Branch,
-
Repo: &repoAt,
-
}
-
}
-
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoPullNSID,
-
Repo: user.Did,
-
Rkey: pull.Rkey,
-
SwapRecord: ex.Cid,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoPull{
-
Title: pull.Title,
-
PullId: int64(pull.PullId),
-
TargetRepo: string(f.RepoAt),
-
TargetBranch: pull.TargetBranch,
-
Patch: patch, // new patch
-
Source: recordPullSource,
-
},
-
},
-
})
-
if err != nil {
-
log.Println("failed to update record", err)
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
-
return
-
}
-
-
if err = tx.Commit(); err != nil {
-
log.Println("failed to commit transaction", err)
-
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
-
return
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
-
return
-
}
-
-
func (s *State) resubmitStackedPullHelper(
-
w http.ResponseWriter,
-
r *http.Request,
-
f *reporesolver.ResolvedRepo,
-
user *oauth.User,
-
pull *db.Pull,
-
patch string,
-
stackId string,
-
) {
-
targetBranch := pull.TargetBranch
-
-
origStack, _ := r.Context().Value("stack").(db.Stack)
-
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
-
if err != nil {
-
log.Println("failed to create resubmitted stack", err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
-
// find the diff between the stacks, first, map them by changeId
-
origById := make(map[string]*db.Pull)
-
newById := make(map[string]*db.Pull)
-
for _, p := range origStack {
-
origById[p.ChangeId] = p
-
}
-
for _, p := range newStack {
-
newById[p.ChangeId] = p
-
}
-
-
// commits that got deleted: corresponding pull is closed
-
// commits that got added: new pull is created
-
// commits that got updated: corresponding pull is resubmitted & new round begins
-
//
-
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
-
additions := make(map[string]*db.Pull)
-
deletions := make(map[string]*db.Pull)
-
unchanged := make(map[string]struct{})
-
updated := make(map[string]struct{})
-
-
// pulls in orignal stack but not in new one
-
for _, op := range origStack {
-
if _, ok := newById[op.ChangeId]; !ok {
-
deletions[op.ChangeId] = op
-
}
-
}
-
-
// pulls in new stack but not in original one
-
for _, np := range newStack {
-
if _, ok := origById[np.ChangeId]; !ok {
-
additions[np.ChangeId] = np
-
}
-
}
-
-
// NOTE: this loop can be written in any of above blocks,
-
// but is written separately in the interest of simpler code
-
for _, np := range newStack {
-
if op, ok := origById[np.ChangeId]; ok {
-
// pull exists in both stacks
-
// TODO: can we avoid reparse?
-
origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
-
newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
-
-
origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
-
newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
-
-
patchutil.SortPatch(newFiles)
-
patchutil.SortPatch(origFiles)
-
-
// text content of patch may be identical, but a jj rebase might have forwarded it
-
//
-
// we still need to update the hash in submission.Patch and submission.SourceRev
-
if patchutil.Equal(newFiles, origFiles) &&
-
origHeader.Title == newHeader.Title &&
-
origHeader.Body == newHeader.Body {
-
unchanged[op.ChangeId] = struct{}{}
-
} else {
-
updated[op.ChangeId] = struct{}{}
-
}
-
}
-
}
-
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Println("failed to start transaction", err)
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
// pds updates to make
-
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
-
-
// deleted pulls are marked as deleted in the DB
-
for _, p := range deletions {
-
err := db.DeletePull(tx, p.RepoAt, p.PullId)
-
if err != nil {
-
log.Println("failed to delete pull", err, p.PullId)
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
-
return
-
}
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
-
RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
-
Collection: tangled.RepoPullNSID,
-
Rkey: p.Rkey,
-
},
-
})
-
}
-
-
// new pulls are created
-
for _, p := range additions {
-
err := db.NewPull(tx, p)
-
if err != nil {
-
log.Println("failed to create pull", err, p.PullId)
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
-
return
-
}
-
-
record := p.AsRecord()
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
-
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
-
Collection: tangled.RepoPullNSID,
-
Rkey: &p.Rkey,
-
Value: &lexutil.LexiconTypeDecoder{
-
Val: &record,
-
},
-
},
-
})
-
}
-
-
// updated pulls are, well, updated; to start a new round
-
for id := range updated {
-
op, _ := origById[id]
-
np, _ := newById[id]
-
-
submission := np.Submissions[np.LastRoundNumber()]
-
-
// resubmit the old pull
-
err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
-
-
if err != nil {
-
log.Println("failed to update pull", err, op.PullId)
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
-
return
-
}
-
-
record := op.AsRecord()
-
record.Patch = submission.Patch
-
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
-
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
-
Collection: tangled.RepoPullNSID,
-
Rkey: op.Rkey,
-
Value: &lexutil.LexiconTypeDecoder{
-
Val: &record,
-
},
-
},
-
})
-
}
-
-
// unchanged pulls are edited without starting a new round
-
//
-
// update source-revs & patches without advancing rounds
-
for changeId := range unchanged {
-
op, _ := origById[changeId]
-
np, _ := newById[changeId]
-
-
origSubmission := op.Submissions[op.LastRoundNumber()]
-
newSubmission := np.Submissions[np.LastRoundNumber()]
-
-
log.Println("moving unchanged change id : ", changeId)
-
-
err := db.UpdatePull(
-
tx,
-
newSubmission.Patch,
-
newSubmission.SourceRev,
-
db.FilterEq("id", origSubmission.ID),
-
)
-
-
if err != nil {
-
log.Println("failed to update pull", err, op.PullId)
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
-
return
-
}
-
-
record := op.AsRecord()
-
record.Patch = newSubmission.Patch
-
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
-
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
-
Collection: tangled.RepoPullNSID,
-
Rkey: op.Rkey,
-
Value: &lexutil.LexiconTypeDecoder{
-
Val: &record,
-
},
-
},
-
})
-
}
-
-
// update parent-change-id relations for the entire stack
-
for _, p := range newStack {
-
err := db.SetPullParentChangeId(
-
tx,
-
p.ParentChangeId,
-
// these should be enough filters to be unique per-stack
-
db.FilterEq("repo_at", p.RepoAt.String()),
-
db.FilterEq("owner_did", p.OwnerDid),
-
db.FilterEq("change_id", p.ChangeId),
-
)
-
-
if err != nil {
-
log.Println("failed to update pull", err, p.PullId)
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
-
return
-
}
-
}
-
-
err = tx.Commit()
-
if err != nil {
-
log.Println("failed to resubmit pull", err)
-
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
-
return
-
}
-
-
client, err := s.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to authorize client")
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
_, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
-
Repo: user.Did,
-
Writes: writes,
-
})
-
if err != nil {
-
log.Println("failed to create stacked pull request", err)
-
s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
-
return
-
}
-
-
func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to resolve repo:", err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
-
return
-
}
-
-
var pullsToMerge db.Stack
-
pullsToMerge = append(pullsToMerge, pull)
-
if pull.IsStacked() {
-
stack, ok := r.Context().Value("stack").(db.Stack)
-
if !ok {
-
log.Println("failed to get stack")
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
-
return
-
}
-
-
// combine patches of substack
-
subStack := stack.StrictlyBelow(pull)
-
// collect the portion of the stack that is mergeable
-
mergeable := subStack.Mergeable()
-
// add to total patch
-
pullsToMerge = append(pullsToMerge, mergeable...)
-
}
-
-
patch := pullsToMerge.CombinedPatch()
-
-
secret, err := db.GetRegistrationKey(s.db, f.Knot)
-
if err != nil {
-
log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
-
ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
-
if err != nil {
-
log.Printf("resolving identity: %s", err)
-
w.WriteHeader(http.StatusNotFound)
-
return
-
}
-
-
email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
-
if err != nil {
-
log.Printf("failed to get primary email: %s", err)
-
}
-
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
-
// Merge the pull request
-
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
-
if err != nil {
-
log.Printf("failed to merge pull request: %s", err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
-
if resp.StatusCode != http.StatusOK {
-
log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Println("failed to start transcation", err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
for _, p := range pullsToMerge {
-
err := db.MergePull(tx, f.RepoAt, p.PullId)
-
if err != nil {
-
log.Printf("failed to update pull request status in database: %s", err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
}
-
-
err = tx.Commit()
-
if err != nil {
-
// TODO: this is unsound, we should also revert the merge from the knotserver here
-
log.Printf("failed to update pull request status in database: %s", err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
-
}
-
-
func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("malformed middleware")
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// auth filter: only owner or collaborators can close
-
roles := f.RolesInRepo(user)
-
isCollaborator := roles.IsCollaborator()
-
isPullAuthor := user.Did == pull.OwnerDid
-
isCloseAllowed := isCollaborator || isPullAuthor
-
if !isCloseAllowed {
-
log.Println("failed to close pull")
-
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
-
return
-
}
-
-
// Start a transaction
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start transaction", err)
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
-
return
-
}
-
defer tx.Rollback()
-
-
var pullsToClose []*db.Pull
-
pullsToClose = append(pullsToClose, pull)
-
-
// if this PR is stacked, then we want to close all PRs below this one on the stack
-
if pull.IsStacked() {
-
stack := r.Context().Value("stack").(db.Stack)
-
subStack := stack.StrictlyBelow(pull)
-
pullsToClose = append(pullsToClose, subStack...)
-
}
-
-
for _, p := range pullsToClose {
-
// Close the pull in the database
-
err = db.ClosePull(tx, f.RepoAt, p.PullId)
-
if err != nil {
-
log.Println("failed to close pull", err)
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
-
return
-
}
-
}
-
-
// Commit the transaction
-
if err = tx.Commit(); err != nil {
-
log.Println("failed to commit transaction", err)
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
-
return
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
-
return
-
}
-
-
func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
-
user := s.oauth.GetUser(r)
-
-
f, err := s.repoResolver.Resolve(r)
-
if err != nil {
-
log.Println("failed to resolve repo", err)
-
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// auth filter: only owner or collaborators can close
-
roles := f.RolesInRepo(user)
-
isCollaborator := roles.IsCollaborator()
-
isPullAuthor := user.Did == pull.OwnerDid
-
isCloseAllowed := isCollaborator || isPullAuthor
-
if !isCloseAllowed {
-
log.Println("failed to close pull")
-
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
-
return
-
}
-
-
// Start a transaction
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start transaction", err)
-
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
-
return
-
}
-
defer tx.Rollback()
-
-
var pullsToReopen []*db.Pull
-
pullsToReopen = append(pullsToReopen, pull)
-
-
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
-
if pull.IsStacked() {
-
stack := r.Context().Value("stack").(db.Stack)
-
subStack := stack.StrictlyAbove(pull)
-
pullsToReopen = append(pullsToReopen, subStack...)
-
}
-
-
for _, p := range pullsToReopen {
-
// Close the pull in the database
-
err = db.ReopenPull(tx, f.RepoAt, p.PullId)
-
if err != nil {
-
log.Println("failed to close pull", err)
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
-
return
-
}
-
}
-
-
// Commit the transaction
-
if err = tx.Commit(); err != nil {
-
log.Println("failed to commit transaction", err)
-
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
-
return
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
-
return
-
}
-
-
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
-
formatPatches, err := patchutil.ExtractPatches(patch)
-
if err != nil {
-
return nil, fmt.Errorf("Failed to extract patches: %v", err)
-
}
-
-
// must have atleast 1 patch to begin with
-
if len(formatPatches) == 0 {
-
return nil, fmt.Errorf("No patches found in the generated format-patch.")
-
}
-
-
// the stack is identified by a UUID
-
var stack db.Stack
-
parentChangeId := ""
-
for _, fp := range formatPatches {
-
// all patches must have a jj change-id
-
changeId, err := fp.ChangeId()
-
if err != nil {
-
return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
-
}
-
-
title := fp.Title
-
body := fp.Body
-
rkey := appview.TID()
-
-
initialSubmission := db.PullSubmission{
-
Patch: fp.Raw,
-
SourceRev: fp.SHA,
-
}
-
pull := db.Pull{
-
Title: title,
-
Body: body,
-
TargetBranch: targetBranch,
-
OwnerDid: user.Did,
-
RepoAt: f.RepoAt,
-
Rkey: rkey,
-
Submissions: []*db.PullSubmission{
-
&initialSubmission,
-
},
-
PullSource: pullSource,
-
Created: time.Now(),
-
-
StackId: stackId,
-
ChangeId: changeId,
-
ParentChangeId: parentChangeId,
-
}
-
-
stack = append(stack, &pull)
-
-
parentChangeId = changeId
-
}
-
-
return stack, nil
-
}
···
+2 -2
appview/oauth/client/oauth_client.go
···
package client
import (
-
oauth "github.com/haileyok/atproto-oauth-golang"
-
"github.com/haileyok/atproto-oauth-golang/helpers"
)
type OAuthClient struct {
···
package client
import (
+
oauth "tangled.sh/icyphox.sh/atproto-oauth"
+
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
)
type OAuthClient struct {
+1 -1
appview/state/repo_util.go
···
for _, v := range emailToDid {
dids = append(dids, v)
}
-
resolvedIdents := s.resolver.ResolveIdents(context.Background(), dids)
didHandleMap := make(map[string]string)
for _, identity := range resolvedIdents {
···
for _, v := range emailToDid {
dids = append(dids, v)
}
+
resolvedIdents := s.idResolver.ResolveIdents(context.Background(), dids)
didHandleMap := make(map[string]string)
for _, identity := range resolvedIdents {
+1 -1
appview/consts.go appview/oauth/consts.go
···
-
package appview
const (
SessionName = "appview-session"
···
+
package oauth
const (
SessionName = "appview-session"
-15
cmd/keyfetch/format.go
···
-
package main
-
-
import (
-
"fmt"
-
)
-
-
func formatKeyData(repoguardPath, gitDir, logPath, endpoint string, data []map[string]interface{}) string {
-
var result string
-
for _, entry := range data {
-
result += fmt.Sprintf(
-
`command="%s -base-dir %s -user %s -log-path %s -internal-api %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n",
-
repoguardPath, gitDir, entry["did"], logPath, endpoint, entry["key"])
-
}
-
return result
-
}
···
-46
cmd/keyfetch/main.go
···
-
// This program must be configured to run as the sshd AuthorizedKeysCommand.
-
// The format looks something like this:
-
// Match User git
-
// AuthorizedKeysCommand /keyfetch -internal-api http://localhost:5444 -repoguard-path /home/git/repoguard
-
// AuthorizedKeysCommandUser nobody
-
//
-
// The command and its parent directories must be owned by root and set to 0755. Hence, the ideal location for this is
-
// somewhere already owned by root so you don't have to mess with directory perms.
-
-
package main
-
-
import (
-
"encoding/json"
-
"flag"
-
"fmt"
-
"io"
-
"log"
-
"net/http"
-
)
-
-
func main() {
-
endpoint := flag.String("internal-api", "http://localhost:5444", "Internal API endpoint")
-
repoguardPath := flag.String("repoguard-path", "/home/git/repoguard", "Path to the repoguard binary")
-
gitDir := flag.String("git-dir", "/home/git", "Path to the git directory")
-
logPath := flag.String("log-path", "/home/git/log", "Path to log file")
-
flag.Parse()
-
-
resp, err := http.Get(*endpoint + "/keys")
-
if err != nil {
-
log.Fatalf("error fetching keys: %v", err)
-
}
-
defer resp.Body.Close()
-
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Fatalf("error reading response body: %v", err)
-
}
-
-
var data []map[string]interface{}
-
err = json.Unmarshal(body, &data)
-
if err != nil {
-
log.Fatalf("error unmarshalling response body: %v", err)
-
}
-
-
fmt.Print(formatKeyData(*repoguardPath, *gitDir, *logPath, *endpoint, data))
-
}
···
-207
cmd/repoguard/main.go
···
-
package main
-
-
import (
-
"context"
-
"flag"
-
"fmt"
-
"log"
-
"net/http"
-
"net/url"
-
"os"
-
"os/exec"
-
"strings"
-
"time"
-
-
securejoin "github.com/cyphar/filepath-securejoin"
-
"tangled.sh/tangled.sh/core/appview/idresolver"
-
)
-
-
var (
-
logger *log.Logger
-
logFile *os.File
-
clientIP string
-
-
// Command line flags
-
incomingUser = flag.String("user", "", "Allowed git user")
-
baseDirFlag = flag.String("base-dir", "/home/git", "Base directory for git repositories")
-
logPathFlag = flag.String("log-path", "/var/log/git-wrapper.log", "Path to log file")
-
endpoint = flag.String("internal-api", "http://localhost:5444", "Internal API endpoint")
-
)
-
-
func main() {
-
flag.Parse()
-
-
defer cleanup()
-
initLogger()
-
-
// Get client IP from SSH environment
-
if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" {
-
parts := strings.Fields(connInfo)
-
if len(parts) > 0 {
-
clientIP = parts[0]
-
}
-
}
-
-
if *incomingUser == "" {
-
exitWithLog("access denied: no user specified")
-
}
-
-
sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND")
-
-
logEvent("Connection attempt", map[string]interface{}{
-
"user": *incomingUser,
-
"command": sshCommand,
-
"client": clientIP,
-
})
-
-
if sshCommand == "" {
-
exitWithLog("access denied: we don't serve interactive shells :)")
-
}
-
-
cmdParts := strings.Fields(sshCommand)
-
if len(cmdParts) < 2 {
-
exitWithLog("invalid command format")
-
}
-
-
gitCommand := cmdParts[0]
-
-
// did:foo/repo-name or
-
// handle/repo-name or
-
// any of the above with a leading slash (/)
-
-
components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
-
logEvent("Command components", map[string]interface{}{
-
"components": components,
-
})
-
if len(components) != 2 {
-
exitWithLog("invalid repo format, needs <user>/<repo> or /<user>/<repo>")
-
}
-
-
didOrHandle := components[0]
-
did := resolveToDid(didOrHandle)
-
repoName := components[1]
-
qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
-
-
validCommands := map[string]bool{
-
"git-receive-pack": true,
-
"git-upload-pack": true,
-
"git-upload-archive": true,
-
}
-
if !validCommands[gitCommand] {
-
exitWithLog("access denied: invalid git command")
-
}
-
-
if gitCommand != "git-upload-pack" {
-
if !isPushPermitted(*incomingUser, qualifiedRepoName) {
-
logEvent("all infos", map[string]interface{}{
-
"did": *incomingUser,
-
"reponame": qualifiedRepoName,
-
})
-
exitWithLog("access denied: user not allowed")
-
}
-
}
-
-
fullPath, _ := securejoin.SecureJoin(*baseDirFlag, qualifiedRepoName)
-
-
logEvent("Processing command", map[string]interface{}{
-
"user": *incomingUser,
-
"command": gitCommand,
-
"repo": repoName,
-
"fullPath": fullPath,
-
"client": clientIP,
-
})
-
-
if gitCommand == "git-upload-pack" {
-
fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!")
-
} else {
-
fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!")
-
}
-
-
cmd := exec.Command(gitCommand, fullPath)
-
cmd.Stdout = os.Stdout
-
cmd.Stderr = os.Stderr
-
cmd.Stdin = os.Stdin
-
-
if err := cmd.Run(); err != nil {
-
exitWithLog(fmt.Sprintf("command failed: %v", err))
-
}
-
-
logEvent("Command completed", map[string]interface{}{
-
"user": *incomingUser,
-
"command": gitCommand,
-
"repo": repoName,
-
"success": true,
-
})
-
}
-
-
func resolveToDid(didOrHandle string) string {
-
resolver := idresolver.DefaultResolver()
-
ident, err := resolver.ResolveIdent(context.Background(), didOrHandle)
-
if err != nil {
-
exitWithLog(fmt.Sprintf("error resolving handle: %v", err))
-
}
-
-
// did:plc:foobarbaz/repo
-
return ident.DID.String()
-
}
-
-
func initLogger() {
-
var err error
-
logFile, err = os.OpenFile(*logPathFlag, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
-
if err != nil {
-
fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err)
-
os.Exit(1)
-
}
-
-
logger = log.New(logFile, "", 0)
-
}
-
-
func logEvent(event string, fields map[string]interface{}) {
-
entry := fmt.Sprintf(
-
"timestamp=%q event=%q",
-
time.Now().Format(time.RFC3339),
-
event,
-
)
-
-
for k, v := range fields {
-
entry += fmt.Sprintf(" %s=%q", k, v)
-
}
-
-
logger.Println(entry)
-
}
-
-
func exitWithLog(message string) {
-
logEvent("Access denied", map[string]interface{}{
-
"error": message,
-
})
-
logFile.Sync()
-
fmt.Fprintf(os.Stderr, "error: %s\n", message)
-
os.Exit(1)
-
}
-
-
func cleanup() {
-
if logFile != nil {
-
logFile.Sync()
-
logFile.Close()
-
}
-
}
-
-
func isPushPermitted(user, qualifiedRepoName string) bool {
-
u, _ := url.Parse(*endpoint + "/push-allowed")
-
q := u.Query()
-
q.Add("user", user)
-
q.Add("repo", qualifiedRepoName)
-
u.RawQuery = q.Encode()
-
-
req, err := http.Get(u.String())
-
if err != nil {
-
exitWithLog(fmt.Sprintf("error verifying permissions: %v", err))
-
}
-
-
logEvent("url", map[string]interface{}{
-
"url": u.String(),
-
"status": req.Status,
-
})
-
-
return req.StatusCode == http.StatusNoContent
-
}
···
+168
knotserver/git/last_commit.go
···
···
+
package git
+
+
import (
+
"bufio"
+
"context"
+
"crypto/sha256"
+
"fmt"
+
"io"
+
"os/exec"
+
"path"
+
"strings"
+
"time"
+
+
"github.com/dgraph-io/ristretto"
+
"github.com/go-git/go-git/v5/plumbing"
+
"github.com/go-git/go-git/v5/plumbing/object"
+
)
+
+
var (
+
commitCache *ristretto.Cache
+
)
+
+
func init() {
+
cache, _ := ristretto.NewCache(&ristretto.Config{
+
NumCounters: 1e7,
+
MaxCost: 1 << 30,
+
BufferItems: 64,
+
TtlTickerDurationInSec: 120,
+
})
+
commitCache = cache
+
}
+
+
func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) {
+
args := []string{}
+
args = append(args, "log")
+
args = append(args, g.h.String())
+
args = append(args, extraArgs...)
+
+
cmd := exec.CommandContext(ctx, "git", args...)
+
cmd.Dir = g.path
+
+
stdout, err := cmd.StdoutPipe()
+
if err != nil {
+
return nil, err
+
}
+
+
if err := cmd.Start(); err != nil {
+
return nil, err
+
}
+
+
return stdout, nil
+
}
+
+
type commit struct {
+
hash plumbing.Hash
+
when time.Time
+
files []string
+
message string
+
}
+
+
func cacheKey(g *GitRepo, path string) string {
+
sep := byte(':')
+
hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, path))
+
return fmt.Sprintf("%x", hash)
+
}
+
+
func (g *GitRepo) calculateCommitTimeIn(ctx context.Context, subtree *object.Tree, parent string, timeout time.Duration) (map[string]commit, error) {
+
ctx, cancel := context.WithTimeout(ctx, timeout)
+
defer cancel()
+
return g.calculateCommitTime(ctx, subtree, parent)
+
}
+
+
func (g *GitRepo) calculateCommitTime(ctx context.Context, subtree *object.Tree, parent string) (map[string]commit, error) {
+
filesToDo := make(map[string]struct{})
+
filesDone := make(map[string]commit)
+
for _, e := range subtree.Entries {
+
fpath := path.Clean(path.Join(parent, e.Name))
+
filesToDo[fpath] = struct{}{}
+
}
+
+
for _, e := range subtree.Entries {
+
f := path.Clean(path.Join(parent, e.Name))
+
cacheKey := cacheKey(g, f)
+
if cached, ok := commitCache.Get(cacheKey); ok {
+
filesDone[f] = cached.(commit)
+
delete(filesToDo, f)
+
} else {
+
filesToDo[f] = struct{}{}
+
}
+
}
+
+
if len(filesToDo) == 0 {
+
return filesDone, nil
+
}
+
+
ctx, cancel := context.WithCancel(ctx)
+
defer cancel()
+
+
pathSpec := "."
+
if parent != "" {
+
pathSpec = parent
+
}
+
output, err := g.streamingGitLog(ctx, "--pretty=format:%H,%ad,%s", "--date=iso", "--name-only", "--", pathSpec)
+
if err != nil {
+
return nil, err
+
}
+
+
reader := bufio.NewReader(output)
+
var current commit
+
for {
+
line, err := reader.ReadString('\n')
+
if err != nil && err != io.EOF {
+
return nil, err
+
}
+
line = strings.TrimSpace(line)
+
+
if line == "" {
+
if !current.hash.IsZero() {
+
// we have a fully parsed commit
+
for _, f := range current.files {
+
if _, ok := filesToDo[f]; ok {
+
filesDone[f] = current
+
delete(filesToDo, f)
+
commitCache.Set(cacheKey(g, f), current, 0)
+
}
+
}
+
+
if len(filesToDo) == 0 {
+
cancel()
+
break
+
}
+
current = commit{}
+
}
+
} else if current.hash.IsZero() {
+
parts := strings.SplitN(line, ",", 3)
+
if len(parts) == 3 {
+
current.hash = plumbing.NewHash(parts[0])
+
current.when, _ = time.Parse("2006-01-02 15:04:05 -0700", parts[1])
+
current.message = parts[2]
+
}
+
} else {
+
// all ancestors along this path should also be included
+
file := path.Clean(line)
+
ancestors := ancestors(file)
+
current.files = append(current.files, file)
+
current.files = append(current.files, ancestors...)
+
}
+
+
if err == io.EOF {
+
break
+
}
+
}
+
+
return filesDone, nil
+
}
+
+
func ancestors(p string) []string {
+
var ancestors []string
+
+
for {
+
p = path.Dir(p)
+
if p == "." || p == "/" {
+
break
+
}
+
ancestors = append(ancestors, p)
+
}
+
return ancestors
+
}
-71
cmd/knotserver/main.go
···
-
package main
-
-
import (
-
"context"
-
"net/http"
-
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/jetstream"
-
"tangled.sh/tangled.sh/core/knotserver"
-
"tangled.sh/tangled.sh/core/knotserver/config"
-
"tangled.sh/tangled.sh/core/knotserver/db"
-
"tangled.sh/tangled.sh/core/log"
-
"tangled.sh/tangled.sh/core/rbac"
-
-
_ "net/http/pprof"
-
)
-
-
func main() {
-
ctx := context.Background()
-
// ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
-
// defer stop()
-
-
l := log.New("knotserver")
-
-
c, err := config.Load(ctx)
-
if err != nil {
-
l.Error("failed to load config", "error", err)
-
return
-
}
-
-
if c.Server.Dev {
-
l.Info("running in dev mode, signature verification is disabled")
-
}
-
-
db, err := db.Setup(c.Server.DBPath)
-
if err != nil {
-
l.Error("failed to setup db", "error", err)
-
return
-
}
-
-
e, err := rbac.NewEnforcer(c.Server.DBPath)
-
if err != nil {
-
l.Error("failed to setup rbac enforcer", "error", err)
-
return
-
}
-
-
e.E.EnableAutoSave(true)
-
-
jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{
-
tangled.PublicKeyNSID,
-
tangled.KnotMemberNSID,
-
}, nil, l, db, true)
-
if err != nil {
-
l.Error("failed to setup jetstream", "error", err)
-
}
-
-
mux, err := knotserver.Setup(ctx, c, db, e, jc, l)
-
if err != nil {
-
l.Error("failed to setup server", "error", err)
-
return
-
}
-
imux := knotserver.Internal(ctx, db, e)
-
-
l.Info("starting internal server", "address", c.Server.InternalListenAddr)
-
go http.ListenAndServe(c.Server.InternalListenAddr, imux)
-
-
l.Info("starting main server", "address", c.Server.ListenAddr)
-
l.Error("server error", "error", http.ListenAndServe(c.Server.ListenAddr, mux))
-
-
return
-
}
···
+1 -1
systemd/knotserver.service
···
[Service]
-
ExecStart=/usr/local/bin/knotserver
Restart=always
User=git
WorkingDirectory=/home/git
···
[Service]
+
ExecStart=/usr/local/bin/knot server
Restart=always
User=git
WorkingDirectory=/home/git
+43
knotserver/notifier/notifier.go
···
···
+
package notifier
+
+
import (
+
"sync"
+
)
+
+
type Notifier struct {
+
subscribers map[chan struct{}]struct{}
+
mu sync.Mutex
+
}
+
+
func New() Notifier {
+
return Notifier{
+
subscribers: make(map[chan struct{}]struct{}),
+
}
+
}
+
+
func (n *Notifier) Subscribe() chan struct{} {
+
ch := make(chan struct{}, 1)
+
n.mu.Lock()
+
n.subscribers[ch] = struct{}{}
+
n.mu.Unlock()
+
return ch
+
}
+
+
func (n *Notifier) Unsubscribe(ch chan struct{}) {
+
n.mu.Lock()
+
delete(n.subscribers, ch)
+
close(ch)
+
n.mu.Unlock()
+
}
+
+
func (n *Notifier) NotifyAll() {
+
n.mu.Lock()
+
for ch := range n.subscribers {
+
select {
+
case ch <- struct{}{}:
+
default:
+
// avoid blocking if channel is full
+
}
+
}
+
n.mu.Unlock()
+
}
-33
docker/docker-compose.yml
···
-
services:
-
knot:
-
build:
-
context: ..
-
dockerfile: docker/Dockerfile
-
environment:
-
KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME}
-
KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET}
-
KNOT_SERVER_DB_PATH: "/app/knotserver.db"
-
KNOT_REPO_SCAN_PATH: "/home/git/repositories"
-
volumes:
-
- "./keys:/etc/ssh/keys"
-
- "./repositories:/home/git/repositories"
-
- "./server:/app"
-
ports:
-
- "2222:22"
-
frontend:
-
image: caddy:2-alpine
-
command: >
-
caddy
-
reverse-proxy
-
--from ${KNOT_SERVER_HOSTNAME}
-
--to knot:5555
-
depends_on:
-
- knot
-
ports:
-
- "443:443"
-
- "443:443/udp"
-
volumes:
-
- caddy_data:/data
-
restart: always
-
volumes:
-
caddy_data:
···
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
···
-
oneshot
···
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
···
-
/etc/s6-overlay/scripts/create-sshd-host-keys
···
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
···
-
longrun
···
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/base
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/create-sshd-host-keys
-3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
···
-
#!/usr/bin/execlineb -P
-
-
/usr/sbin/sshd -e -D
···
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
···
-
longrun
···
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd
-21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
···
-
#!/usr/bin/execlineb -P
-
-
foreground {
-
if -n { test -d /etc/ssh/keys }
-
mkdir /etc/ssh/keys
-
}
-
-
foreground {
-
if -n { test -f /etc/ssh/keys/ssh_host_rsa_key }
-
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_rsa_key -q -N ""
-
}
-
-
foreground {
-
if -n { test -f /etc/ssh/keys/ssh_host_ecdsa_key }
-
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ecdsa_key -q -N ""
-
}
-
-
foreground {
-
if -n { test -f /etc/ssh/keys/ssh_host_ed25519_key }
-
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ed25519_key -q -N ""
-
}
···
+14
appview/cache/cache.go
···
···
+
package cache
+
+
import "github.com/redis/go-redis/v9"
+
+
type Cache struct {
+
*redis.Client
+
}
+
+
func New(addr string) *Cache {
+
rdb := redis.NewClient(&redis.Options{
+
Addr: addr,
+
})
+
return &Cache{rdb}
+
}
+18
nix/pkgs/sqlite-lib.nix
···
···
+
{
+
gcc,
+
stdenv,
+
sqlite-lib-src,
+
}:
+
stdenv.mkDerivation {
+
name = "sqlite-lib";
+
src = sqlite-lib-src;
+
nativeBuildInputs = [gcc];
+
buildPhase = ''
+
gcc -c sqlite3.c
+
ar rcs libsqlite3.a sqlite3.o
+
ranlib libsqlite3.a
+
mkdir -p $out/include $out/lib
+
cp *.h $out/include
+
cp libsqlite3.a $out/lib
+
'';
+
}
+20
nix/pkgs/knot.nix
···
···
+
{
+
knot-unwrapped,
+
makeWrapper,
+
git,
+
}:
+
knot-unwrapped.overrideAttrs (after: before: {
+
nativeBuildInputs = (before.nativeBuildInputs or []) ++ [makeWrapper];
+
+
installPhase = ''
+
runHook preInstall
+
+
mkdir -p $out/bin
+
cp $GOPATH/bin/knot $out/bin/knot
+
+
wrapProgram $out/bin/knot \
+
--prefix PATH : ${git}/bin
+
+
runHook postInstall
+
'';
+
})
-70
knotserver/db/oplog.go
···
-
package db
-
-
import (
-
"fmt"
-
-
"tangled.sh/tangled.sh/core/knotserver/notifier"
-
)
-
-
type Op struct {
-
Tid string // time based ID, easy to enumerate & monotonic
-
Did string // did of pusher
-
Repo string // <did/repo> fully qualified repo
-
OldSha string // old sha of reference being updated
-
NewSha string // new sha of reference being updated
-
Ref string // the reference being updated
-
}
-
-
func (d *DB) InsertOp(op Op, notifier *notifier.Notifier) error {
-
_, err := d.db.Exec(
-
`insert into oplog (tid, did, repo, old_sha, new_sha, ref) values (?, ?, ?, ?, ?, ?)`,
-
op.Tid,
-
op.Did,
-
op.Repo,
-
op.OldSha,
-
op.NewSha,
-
op.Ref,
-
)
-
if err != nil {
-
return err
-
}
-
-
notifier.NotifyAll()
-
return nil
-
}
-
-
func (d *DB) GetOps(cursor string) ([]Op, error) {
-
whereClause := ""
-
args := []any{}
-
if cursor != "" {
-
whereClause = "where tid > ?"
-
args = append(args, cursor)
-
}
-
-
query := fmt.Sprintf(`
-
select tid, did, repo, old_sha, new_sha, ref
-
from oplog
-
%s
-
order by tid asc
-
limit 100
-
`, whereClause)
-
-
rows, err := d.db.Query(query, args...)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
var ops []Op
-
for rows.Next() {
-
var op Op
-
rows.Scan(&op.Tid, &op.Did, &op.Repo, &op.OldSha, &op.NewSha, &op.Ref)
-
ops = append(ops, op)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return ops, nil
-
}
···
+9
spindle/tid.go
···
···
+
package spindle
+
+
import "github.com/bluesky-social/indigo/atproto/syntax"
+
+
var TIDClock = syntax.NewTIDClock(0)
+
+
func TID() string {
+
return TIDClock.Next().String()
+
}
knotserver/notifier/notifier.go notifier/notifier.go
+23
knotclient/cursor/memory.go
···
···
+
package cursor
+
+
import (
+
"sync"
+
)
+
+
type MemoryStore struct {
+
store sync.Map
+
}
+
+
func (m *MemoryStore) Set(knot string, cursor int64) {
+
m.store.Store(knot, cursor)
+
}
+
+
func (m *MemoryStore) Get(knot string) (cursor int64) {
+
if result, ok := m.store.Load(knot); ok {
+
if val, ok := result.(int64); ok {
+
return val
+
}
+
}
+
+
return 0
+
}
+43
knotclient/cursor/redis.go
···
···
+
package cursor
+
+
import (
+
"context"
+
"fmt"
+
"strconv"
+
+
"tangled.sh/tangled.sh/core/appview/cache"
+
)
+
+
const (
+
cursorKey = "cursor:%s"
+
)
+
+
type RedisStore struct {
+
rdb *cache.Cache
+
}
+
+
func NewRedisCursorStore(cache *cache.Cache) RedisStore {
+
return RedisStore{
+
rdb: cache,
+
}
+
}
+
+
func (r *RedisStore) Set(knot string, cursor int64) {
+
key := fmt.Sprintf(cursorKey, knot)
+
r.rdb.Set(context.Background(), key, cursor, 0)
+
}
+
+
func (r *RedisStore) Get(knot string) (cursor int64) {
+
key := fmt.Sprintf(cursorKey, knot)
+
val, err := r.rdb.Get(context.Background(), key).Result()
+
if err != nil {
+
return 0
+
}
+
cursor, err = strconv.ParseInt(val, 10, 64)
+
if err != nil {
+
// TODO: log here
+
return 0
+
}
+
+
return cursor
+
}
+6
knotclient/cursor/store.go
···
···
+
package cursor
+
+
type Store interface {
+
Set(knot string, cursor int64)
+
Get(knot string) (cursor int64)
+
}
+83
knotclient/cursor/sqlite.go
···
···
+
package cursor
+
+
import (
+
"database/sql"
+
"fmt"
+
+
_ "github.com/mattn/go-sqlite3"
+
)
+
+
type SqliteStore struct {
+
db *sql.DB
+
tableName string
+
}
+
+
type SqliteStoreOpt func(*SqliteStore)
+
+
func WithTableName(name string) SqliteStoreOpt {
+
return func(s *SqliteStore) {
+
s.tableName = name
+
}
+
}
+
+
func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) {
+
db, err := sql.Open("sqlite3", dbPath)
+
if err != nil {
+
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
+
}
+
+
store := &SqliteStore{
+
db: db,
+
tableName: "cursors",
+
}
+
+
for _, o := range opts {
+
o(store)
+
}
+
+
if err := store.init(); err != nil {
+
return nil, err
+
}
+
+
return store, nil
+
}
+
+
func (s *SqliteStore) init() error {
+
createTable := fmt.Sprintf(`
+
create table if not exists %s (
+
knot text primary key,
+
cursor text
+
);`, s.tableName)
+
_, err := s.db.Exec(createTable)
+
return err
+
}
+
+
func (s *SqliteStore) Set(knot string, cursor int64) {
+
query := fmt.Sprintf(`
+
insert into %s (knot, cursor)
+
values (?, ?)
+
on conflict(knot) do update set cursor=excluded.cursor;
+
`, s.tableName)
+
+
_, err := s.db.Exec(query, knot, cursor)
+
+
if err != nil {
+
// TODO: log here
+
}
+
}
+
+
func (s *SqliteStore) Get(knot string) (cursor int64) {
+
query := fmt.Sprintf(`
+
select cursor from %s where knot = ?;
+
`, s.tableName)
+
err := s.db.QueryRow(query, knot).Scan(&cursor)
+
+
if err != nil {
+
if err != sql.ErrNoRows {
+
// TODO: log here
+
}
+
return 0
+
}
+
+
return cursor
+
}
+1 -1
spindle/tid.go tid/tid.go
···
-
package spindle
import "github.com/bluesky-social/indigo/atproto/syntax"
···
+
package tid
import "github.com/bluesky-social/indigo/atproto/syntax"
+44
spindle/db/known_dids.go
···
···
+
package db
+
+
func (d *DB) AddDid(did string) error {
+
_, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did)
+
return err
+
}
+
+
func (d *DB) RemoveDid(did string) error {
+
_, err := d.Exec(`delete from known_dids where did = ?`, did)
+
return err
+
}
+
+
func (d *DB) GetAllDids() ([]string, error) {
+
var dids []string
+
+
rows, err := d.Query(`select did from known_dids`)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var did string
+
if err := rows.Scan(&did); err != nil {
+
return nil, err
+
}
+
dids = append(dids, did)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return dids, nil
+
}
+
+
func (d *DB) HasKnownDids() bool {
+
var count int
+
err := d.QueryRow(`select count(*) from known_dids`).Scan(&count)
+
if err != nil {
+
return false
+
}
+
return count > 0
+
}
+4
lexicons/repo.json
···
"type": "string",
"description": "knot where the repo was created"
},
"description": {
"type": "string",
"format": "datetime",
···
"type": "string",
"description": "knot where the repo was created"
},
+
"spindle": {
+
"type": "string",
+
"description": "CI runner to send jobs to and receive results from"
+
},
"description": {
"type": "string",
"format": "datetime",
+22
api/tangled/tangledspindle.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.spindle
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
SpindleNSID = "sh.tangled.spindle"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.spindle", &Spindle{})
+
} //
+
// RECORDTYPE: Spindle
+
type Spindle struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.spindle" cborgen:"$type,const=sh.tangled.spindle"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
}
+25
lexicons/spindle.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.spindle",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "any",
+
"record": {
+
"type": "object",
+
"required": [
+
"createdAt"
+
],
+
"properties": {
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+
+6
knotclient/events.go
···
}
func (c *EventConsumer) AddSource(ctx context.Context, s EventSource) {
c.cfgMu.Lock()
c.cfg.Sources[s] = struct{}{}
c.wg.Add(1)
···
}
func (c *EventConsumer) AddSource(ctx context.Context, s EventSource) {
+
// we are already listening to this source
+
if _, ok := c.cfg.Sources[s]; ok {
+
c.logger.Info("source already present", "source", s)
+
return
+
}
+
c.cfgMu.Lock()
c.cfg.Sources[s] = struct{}{}
c.wg.Add(1)
knotclient/cursor/memory.go eventconsumer/cursor/memory.go
knotclient/cursor/store.go eventconsumer/cursor/store.go
+39
eventconsumer/knot.go
···
···
+
package eventconsumer
+
+
import (
+
"fmt"
+
"net/url"
+
)
+
+
type KnotSource struct {
+
Knot string
+
}
+
+
func (k KnotSource) Key() string {
+
return k.Knot
+
}
+
+
func (k KnotSource) Url(cursor int64, dev bool) (*url.URL, error) {
+
scheme := "wss"
+
if dev {
+
scheme = "ws"
+
}
+
+
u, err := url.Parse(scheme + "://" + k.Knot + "/events")
+
if err != nil {
+
return nil, err
+
}
+
+
if cursor != 0 {
+
query := url.Values{}
+
query.Add("cursor", fmt.Sprintf("%d", cursor))
+
u.RawQuery = query.Encode()
+
}
+
return u, nil
+
}
+
+
func NewKnotSource(knot string) KnotSource {
+
return KnotSource{
+
Knot: knot,
+
}
+
}
+39
eventconsumer/spindle.go
···
···
+
package eventconsumer
+
+
import (
+
"fmt"
+
"net/url"
+
)
+
+
type SpindleSource struct {
+
Spindle string
+
}
+
+
func (s SpindleSource) Key() string {
+
return s.Spindle
+
}
+
+
func (s SpindleSource) Url(cursor int64, dev bool) (*url.URL, error) {
+
scheme := "wss"
+
if dev {
+
scheme = "ws"
+
}
+
+
u, err := url.Parse(scheme + "://" + s.Spindle + "/events")
+
if err != nil {
+
return nil, err
+
}
+
+
if cursor != 0 {
+
query := url.Values{}
+
query.Add("cursor", fmt.Sprintf("%d", cursor))
+
u.RawQuery = query.Encode()
+
}
+
return u, nil
+
}
+
+
func NewSpindleSource(spindle string) SpindleSource {
+
return SpindleSource{
+
Spindle: spindle,
+
}
+
}
-119
appview/pages/templates/repo/fragments/pipelineStatusSymbol.html
···
-
{{ define "repo/fragments/pipelineStatusSymbol" }}
-
<div class="group relative inline-block">
-
{{ block "icon" $ }} {{ end }}
-
{{ block "tooltip" $ }} {{ end }}
-
</div>
-
{{ end }}
-
-
{{ define "icon" }}
-
<div class="cursor-pointer">
-
{{ $c := .Counts }}
-
{{ $statuses := .Statuses }}
-
{{ $total := len $statuses }}
-
{{ $success := index $c "success" }}
-
{{ $allPass := eq $success $total }}
-
-
{{ if $allPass }}
-
<div class="flex gap-1 items-center">
-
{{ i "check" "size-4 text-green-600 dark:text-green-400 " }}
-
<span>{{ $total }}/{{ $total }}</span>
-
</div>
-
{{ else }}
-
{{ $radius := f64 8 }}
-
{{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }}
-
{{ $offset := 0.0 }}
-
<div class="flex gap-1 items-center">
-
<svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20">
-
<circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/>
-
-
{{ range $kind, $count := $c }}
-
{{ $color := "" }}
-
{{ if or (eq $kind "pending") (eq $kind "running") }}
-
{{ $color = "#eab308" }}
-
{{ else if eq $kind "success" }}
-
{{ $color = "#10b981" }}
-
{{ else if eq $kind "cancelled" }}
-
{{ $color = "#6b7280" }}
-
{{ else }}
-
{{ $color = "#ef4444" }}
-
{{ end }}
-
-
{{ $percent := divf64 (f64 $count) (f64 $total) }}
-
{{ $length := mulf64 $percent $circumference }}
-
-
<circle
-
cx="10" cy="10" r="{{ $radius }}"
-
fill="none"
-
stroke="{{ $color }}"
-
stroke-width="2"
-
stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}"
-
stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}"
-
/>
-
{{ $offset = addf64 $offset $length }}
-
{{ end }}
-
</svg>
-
<span>{{$success}}/{{ $total }}</span>
-
</div>
-
{{ end }}
-
</div>
-
{{ end }}
-
-
{{ define "tooltip" }}
-
<div class="absolute z-[9999] hidden group-hover:block bg-white dark:bg-gray-900 text-sm text-black dark:text-white rounded-md shadow p-2 w-80 top-full mt-2">
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700">
-
{{ range $name, $all := .Statuses }}
-
<div class="flex items-center justify-between p-1">
-
{{ $lastStatus := $all.Latest }}
-
{{ $kind := $lastStatus.Status.String }}
-
-
{{ $icon := "dot" }}
-
{{ $color := "text-gray-600 dark:text-gray-500" }}
-
{{ $text := "Failed" }}
-
{{ $time := "" }}
-
-
{{ if eq $kind "pending" }}
-
{{ $icon = "circle-dashed" }}
-
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
-
{{ $text = "Queued" }}
-
{{ $time = timeFmt $lastStatus.Created }}
-
{{ else if eq $kind "running" }}
-
{{ $icon = "circle-dashed" }}
-
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
-
{{ $text = "Running" }}
-
{{ $time = timeFmt $lastStatus.Created }}
-
{{ else if eq $kind "success" }}
-
{{ $icon = "check" }}
-
{{ $color = "text-green-600 dark:text-green-500" }}
-
{{ $text = "Success" }}
-
{{ with $all.TimeTaken }}
-
{{ $time = durationFmt . }}
-
{{ end }}
-
{{ else if eq $kind "cancelled" }}
-
{{ $icon = "circle-slash" }}
-
{{ $color = "text-gray-600 dark:text-gray-500" }}
-
{{ $text = "Cancelled" }}
-
{{ with $all.TimeTaken }}
-
{{ $time = durationFmt . }}
-
{{ end }}
-
{{ else }}
-
{{ $icon = "x" }}
-
{{ $color = "text-red-600 dark:text-red-500" }}
-
{{ $text = "Failed" }}
-
{{ with $all.TimeTaken }}
-
{{ $time = durationFmt . }}
-
{{ end }}
-
{{ end }}
-
-
<div id="left" class="flex items-center gap-2 flex-shrink-0">
-
{{ i $icon "size-4" $color }}
-
{{ $name }}
-
</div>
-
<div id="right" class="flex items-center gap-2 flex-shrink-0">
-
<span class="font-bold">{{ $text }}</span>
-
<time class="text-gray-400 dark:text-gray-600">{{ $time }}</time>
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
···
+118
appview/spindleverify/verify.go
···
···
+
package spindleverify
+
+
import (
+
"context"
+
"errors"
+
"fmt"
+
"io"
+
"net/http"
+
"strings"
+
"time"
+
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/rbac"
+
)
+
+
var (
+
FetchError = errors.New("failed to fetch owner")
+
)
+
+
// TODO: move this to "spindleclient" or similar
+
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
+
scheme := "https"
+
if dev {
+
scheme = "http"
+
}
+
+
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
+
req, err := http.NewRequest("GET", url, nil)
+
if err != nil {
+
return "", err
+
}
+
+
client := &http.Client{
+
Timeout: 1 * time.Second,
+
}
+
+
resp, err := client.Do(req.WithContext(ctx))
+
if err != nil || resp.StatusCode != 200 {
+
return "", fmt.Errorf("failed to fetch /owner")
+
}
+
+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
+
if err != nil {
+
return "", fmt.Errorf("failed to read /owner response: %w", err)
+
}
+
+
did := strings.TrimSpace(string(body))
+
if did == "" {
+
return "", fmt.Errorf("empty DID in /owner response")
+
}
+
+
return did, nil
+
}
+
+
type OwnerMismatch struct {
+
expected string
+
observed string
+
}
+
+
func (e *OwnerMismatch) Error() string {
+
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
+
}
+
+
func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error {
+
// begin verification
+
observedOwner, err := fetchOwner(ctx, instance, dev)
+
if err != nil {
+
return fmt.Errorf("%w: %w", FetchError, err)
+
}
+
+
if observedOwner != expectedOwner {
+
return &OwnerMismatch{
+
expected: expectedOwner,
+
observed: observedOwner,
+
}
+
}
+
+
return nil
+
}
+
+
// mark this spindle as verified in the DB and add this user as its owner
+
func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
+
tx, err := d.Begin()
+
if err != nil {
+
return 0, fmt.Errorf("failed to create txn: %w", err)
+
}
+
defer func() {
+
tx.Rollback()
+
e.E.LoadPolicy()
+
}()
+
+
// mark this spindle as verified in the db
+
rowId, err := db.VerifySpindle(
+
tx,
+
db.FilterEq("owner", owner),
+
db.FilterEq("instance", instance),
+
)
+
if err != nil {
+
return 0, fmt.Errorf("failed to write to DB: %w", err)
+
}
+
+
err = e.AddSpindleOwner(instance, owner)
+
if err != nil {
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
return 0, fmt.Errorf("failed to commit txn: %w", err)
+
}
+
+
err = e.E.SavePolicy()
+
if err != nil {
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
+
}
+
+
return rowId, nil
+
}
+27 -12
spindle/engine/logger.go
···
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
// TODO: emit stream
-
return &jsonWriter{logger: l, kind: models.LogKindData}
}
-
func (l *WorkflowLogger) ControlWriter() io.Writer {
-
return &jsonWriter{logger: l, kind: models.LogKindControl}
}
-
type jsonWriter struct {
logger *WorkflowLogger
-
kind models.LogKind
}
-
func (w *jsonWriter) Write(p []byte) (int, error) {
line := strings.TrimRight(string(p), "\r\n")
-
-
entry := models.LogLine{
-
Kind: w.kind,
-
Content: line,
}
if err := w.logger.encoder.Encode(entry); err != nil {
return 0, err
}
-
-
return len(p), nil
}
···
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
// TODO: emit stream
+
return &dataWriter{
+
logger: l,
+
stream: stream,
+
}
}
+
func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer {
+
return &controlWriter{
+
logger: l,
+
idx: idx,
+
step: step,
+
}
}
+
type dataWriter struct {
logger *WorkflowLogger
+
stream string
}
+
func (w *dataWriter) Write(p []byte) (int, error) {
line := strings.TrimRight(string(p), "\r\n")
+
entry := models.NewDataLogLine(line, w.stream)
+
if err := w.logger.encoder.Encode(entry); err != nil {
+
return 0, err
}
+
return len(p), nil
+
}
+
+
type controlWriter struct {
+
logger *WorkflowLogger
+
idx int
+
step models.Step
+
}
+
func (w *controlWriter) Write(_ []byte) (int, error) {
+
entry := models.NewControlLogLine(w.idx, w.step)
if err := w.logger.encoder.Encode(entry); err != nil {
return 0, err
}
+
return len(w.step.Name), nil
}
+85 -54
lexicons/pipeline.json
···
"key": "tid",
"record": {
"type": "object",
-
"required": ["triggerMetadata", "workflows"],
"properties": {
"triggerMetadata": {
"type": "ref",
···
},
"triggerMetadata": {
"type": "object",
-
"required": ["kind", "repo"],
"properties": {
"kind": {
"type": "string",
-
"enum": ["push", "pull_request", "manual"]
},
"repo": {
"type": "ref",
···
},
"triggerRepo": {
"type": "object",
-
"required": ["knot", "did", "repo", "defaultBranch"],
"properties": {
"knot": {
"type": "string"
···
},
"pushTriggerData": {
"type": "object",
-
"required": ["ref", "newSha", "oldSha"],
"properties": {
"ref": {
"type": "string"
···
},
"pullRequestTriggerData": {
"type": "object",
-
"required": ["sourceBranch", "targetBranch", "sourceSha", "action"],
"properties": {
"sourceBranch": {
"type": "string"
···
"inputs": {
"type": "array",
"items": {
-
"type": "object",
-
"required": ["key", "value"],
-
"properties": {
-
"key": {
-
"type": "string"
-
},
-
"value": {
-
"type": "string"
-
}
-
}
}
}
}
},
"workflow": {
"type": "object",
-
"required": ["name", "dependencies", "steps", "environment", "clone"],
"properties": {
"name": {
"type": "string"
},
"dependencies": {
-
"type": "ref",
-
"ref": "#dependencies"
},
"steps": {
"type": "array",
···
"environment": {
"type": "array",
"items": {
-
"type": "object",
-
"required": ["key", "value"],
-
"properties": {
-
"key": {
-
"type": "string"
-
},
-
"value": {
-
"type": "string"
-
}
-
}
}
},
"clone": {
···
}
}
},
-
"dependencies": {
-
"type": "array",
-
"items": {
-
"type": "object",
-
"required": ["registry", "packages"],
-
"properties": {
-
"registry": {
"type": "string"
-
},
-
"packages": {
-
"type": "array",
-
"items": {
-
"type": "string"
-
}
}
}
}
},
"cloneOpts": {
"type": "object",
-
"required": ["skip", "depth", "submodules"],
"properties": {
"skip": {
"type": "boolean"
···
},
"step": {
"type": "object",
-
"required": ["name", "command"],
"properties": {
"name": {
"type": "string"
···
"environment": {
"type": "array",
"items": {
-
"type": "object",
-
"required": ["key", "value"],
-
"properties": {
-
"key": {
-
"type": "string"
-
},
-
"value": {
-
"type": "string"
-
}
-
}
}
}
}
}
}
}
···
"key": "tid",
"record": {
"type": "object",
+
"required": [
+
"triggerMetadata",
+
"workflows"
+
],
"properties": {
"triggerMetadata": {
"type": "ref",
···
},
"triggerMetadata": {
"type": "object",
+
"required": [
+
"kind",
+
"repo"
+
],
"properties": {
"kind": {
"type": "string",
+
"enum": [
+
"push",
+
"pull_request",
+
"manual"
+
]
},
"repo": {
"type": "ref",
···
},
"triggerRepo": {
"type": "object",
+
"required": [
+
"knot",
+
"did",
+
"repo",
+
"defaultBranch"
+
],
"properties": {
"knot": {
"type": "string"
···
},
"pushTriggerData": {
"type": "object",
+
"required": [
+
"ref",
+
"newSha",
+
"oldSha"
+
],
"properties": {
"ref": {
"type": "string"
···
},
"pullRequestTriggerData": {
"type": "object",
+
"required": [
+
"sourceBranch",
+
"targetBranch",
+
"sourceSha",
+
"action"
+
],
"properties": {
"sourceBranch": {
"type": "string"
···
"inputs": {
"type": "array",
"items": {
+
"type": "ref",
+
"ref": "#pair"
}
}
}
},
"workflow": {
"type": "object",
+
"required": [
+
"name",
+
"dependencies",
+
"steps",
+
"environment",
+
"clone"
+
],
"properties": {
"name": {
"type": "string"
},
"dependencies": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#dependency"
+
}
},
"steps": {
"type": "array",
···
"environment": {
"type": "array",
"items": {
+
"type": "ref",
+
"ref": "#pair"
}
},
"clone": {
···
}
}
},
+
"dependency": {
+
"type": "object",
+
"required": [
+
"registry",
+
"packages"
+
],
+
"properties": {
+
"registry": {
+
"type": "string"
+
},
+
"packages": {
+
"type": "array",
+
"items": {
"type": "string"
}
}
}
},
"cloneOpts": {
"type": "object",
+
"required": [
+
"skip",
+
"depth",
+
"submodules"
+
],
"properties": {
"skip": {
"type": "boolean"
···
},
"step": {
"type": "object",
+
"required": [
+
"name",
+
"command"
+
],
"properties": {
"name": {
"type": "string"
···
"environment": {
"type": "array",
"items": {
+
"type": "ref",
+
"ref": "#pair"
}
}
}
+
},
+
"pair": {
+
"type": "object",
+
"required": [
+
"key",
+
"value"
+
],
+
"properties": {
+
"key": {
+
"type": "string"
+
},
+
"value": {
+
"type": "string"
+
}
+
}
}
}
}
+1 -1
spindle/engine/envs_test.go
···
if got == nil {
got = EnvVars{}
}
-
assert.Equal(t, tt.want, got)
})
}
}
···
if got == nil {
got = EnvVars{}
}
+
assert.ElementsMatch(t, tt.want, got)
})
}
}
+8 -1
docs/spindle/hosting.md
···
go build -o cmd/spindle/spindle cmd/spindle/main.go
```
-
3. **Run the Spindle binary.**
```shell
./cmd/spindle/spindle
···
go build -o cmd/spindle/spindle cmd/spindle/main.go
```
+
3. **Create the log directory.**
+
+
```shell
+
sudo mkdir -p /var/log/spindle
+
sudo chown $USER:$USER -R /var/log/spindle
+
```
+
+
4. **Run the Spindle binary.**
```shell
./cmd/spindle/spindle
+24
api/tangled/feedreaction.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.feed.reaction
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
FeedReactionNSID = "sh.tangled.feed.reaction"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{})
+
} //
+
// RECORDTYPE: FeedReaction
+
type FeedReaction struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Reaction string `json:"reaction" cborgen:"reaction"`
+
Subject string `json:"subject" cborgen:"subject"`
+
}
+34
lexicons/feed/reaction.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.feed.reaction",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"subject",
+
"reaction",
+
"createdAt"
+
],
+
"properties": {
+
"subject": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"reaction": {
+
"type": "string",
+
"enum": [ "๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ˜†", "๐ŸŽ‰", "๐Ÿซค", "โค๏ธ", "๐Ÿš€", "๐Ÿ‘€" ]
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
-93
appview/pages/templates/knots.html
···
-
{{ define "title" }}knots{{ end }}
-
{{ define "content" }}
-
<div class="p-6">
-
<p class="text-xl font-bold dark:text-white">Knots</p>
-
</div>
-
<div class="flex flex-col">
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2>
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
-
<p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p>
-
<form
-
hx-post="/knots/key"
-
class="max-w-2xl mb-8 space-y-4"
-
hx-indicator="#generate-knot-key-spinner"
-
>
-
<input
-
type="text"
-
id="domain"
-
name="domain"
-
placeholder="knot.example.com"
-
required
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
-
>
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex items-center" type="submit">
-
<span>generate key</span>
-
<span id="generate-knot-key-spinner" class="group">
-
{{ i "loader-circle" "pl-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
<div id="settings-knots-error" class="error dark:text-red-400"></div>
-
</form>
-
</section>
-
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2>
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
-
<div id="knots-list" class="flex flex-col gap-6 mb-8">
-
{{ range .Registrations }}
-
{{ if .Registered }}
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
-
<div class="flex flex-col gap-1">
-
<div class="inline-flex items-center gap-4">
-
{{ i "git-branch" "w-3 h-3 dark:text-gray-300" }}
-
<a href="/knots/{{ .Domain }}">
-
<p class="font-bold dark:text-white">{{ .Domain }}</p>
-
</a>
-
</div>
-
<p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p>
-
<p class="text-sm text-gray-500 dark:text-gray-400">registered {{ template "repo/fragments/time" .Registered }}</p>
-
</div>
-
</div>
-
{{ end }}
-
{{ else }}
-
<p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p>
-
{{ end }}
-
</div>
-
</section>
-
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2>
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
-
<div id="pending-knots-list" class="flex flex-col gap-6 mb-8">
-
{{ range .Registrations }}
-
{{ if not .Registered }}
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
-
<div class="flex flex-col gap-1">
-
<div class="inline-flex items-center gap-4">
-
<p class="font-bold dark:text-white">{{ .Domain }}</p>
-
<div class="inline-flex items-center gap-1">
-
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">
-
pending
-
</span>
-
</div>
-
</div>
-
<p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p>
-
<p class="text-sm text-gray-500 dark:text-gray-400">created {{ template "repo/fragments/time" .Created }}</p>
-
</div>
-
<div class="flex gap-2 items-center">
-
<button
-
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group"
-
hx-post="/knots/{{ .Domain }}/init"
-
>
-
{{ i "square-play" "w-5 h-5" }}
-
<span class="hidden md:inline">initialize</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</div>
-
</div>
-
{{ end }}
-
{{ else }}
-
<p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p>
-
{{ end }}
-
</div>
-
</section>
-
</div>
-
{{ end }}
···
+42
knotserver/git/cmd.go
···
···
+
package git
+
+
import (
+
"fmt"
+
"os/exec"
+
)
+
+
const (
+
fieldSeparator = "\x1f" // ASCII Unit Separator
+
recordSeparator = "\x1e" // ASCII Record Separator
+
)
+
+
func (g *GitRepo) runGitCmd(command string, extraArgs ...string) ([]byte, error) {
+
var args []string
+
args = append(args, command)
+
args = append(args, extraArgs...)
+
+
cmd := exec.Command("git", args...)
+
cmd.Dir = g.path
+
+
out, err := cmd.Output()
+
if err != nil {
+
if exitErr, ok := err.(*exec.ExitError); ok {
+
return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr))
+
}
+
return nil, err
+
}
+
+
return out, nil
+
}
+
+
func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) {
+
return g.runGitCmd("rev-list", extraArgs...)
+
}
+
+
func (g *GitRepo) forEachRef(extraArgs ...string) ([]byte, error) {
+
return g.runGitCmd("for-each-ref", extraArgs...)
+
}
+
+
func (g *GitRepo) revParse(extraArgs ...string) ([]byte, error) {
+
return g.runGitCmd("rev-parse", extraArgs...)
+
}
-11
appview/tid.go
···
-
package appview
-
-
import (
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
)
-
-
var c syntax.TIDClock = syntax.NewTIDClock(0)
-
-
func TID() string {
-
return c.Next().String()
-
}
···
+33 -4
avatar/src/index.js
···
export default {
async fetch(request, env) {
const url = new URL(request.url);
const { pathname, searchParams } = url;
···
const profile = await profileResponse.json();
const avatar = profile.avatar;
-
if (!avatar) {
-
return new Response(`avatar not found for ${actor}.`, { status: 404 });
}
// Resize if requested
let avatarResponse;
if (resizeToTiny) {
-
avatarResponse = await fetch(avatar, {
cf: {
image: {
width: 32,
···
},
});
} else {
-
avatarResponse = await fetch(avatar);
}
if (!avatarResponse.ok) {
···
export default {
async fetch(request, env) {
+
// Helper function to generate a color from a string
+
const stringToColor = (str) => {
+
let hash = 0;
+
for (let i = 0; i < str.length; i++) {
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
+
}
+
let color = "#";
+
for (let i = 0; i < 3; i++) {
+
const value = (hash >> (i * 8)) & 0xff;
+
color += ("00" + value.toString(16)).substr(-2);
+
}
+
return color;
+
};
+
const url = new URL(request.url);
const { pathname, searchParams } = url;
···
const profile = await profileResponse.json();
const avatar = profile.avatar;
+
let avatarUrl = profile.avatar;
+
+
if (!avatarUrl) {
+
// Generate a random color based on the actor string
+
const bgColor = stringToColor(actor);
+
const size = resizeToTiny ? 32 : 128;
+
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`;
+
const svgData = new TextEncoder().encode(svg);
+
+
response = new Response(svgData, {
+
headers: {
+
"Content-Type": "image/svg+xml",
+
"Cache-Control": "public, max-age=43200",
+
},
+
});
+
await cache.put(cacheKey, response.clone());
+
return response;
}
// Resize if requested
let avatarResponse;
if (resizeToTiny) {
+
avatarResponse = await fetch(avatarUrl, {
cf: {
image: {
width: 32,
···
},
});
} else {
+
avatarResponse = await fetch(avatarUrl);
}
if (!avatarResponse.ok) {
-48
appview/pages/templates/repo/fragments/repoActions.html
···
-
{{ define "repo/fragments/repoActions" }}
-
<div class="flex items-center gap-2 z-auto">
-
<button
-
id="starBtn"
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
-
{{ if .IsStarred }}
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ else }}
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ end }}
-
-
hx-trigger="click"
-
hx-target="#starBtn"
-
hx-swap="outerHTML"
-
hx-disabled-elt="#starBtn"
-
>
-
{{ if .IsStarred }}
-
{{ i "star" "w-4 h-4 fill-current" }}
-
{{ else }}
-
{{ i "star" "w-4 h-4" }}
-
{{ end }}
-
<span class="text-sm">
-
{{ .Stats.StarCount }}
-
</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ if .DisableFork }}
-
<button
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
-
disabled
-
title="Empty repositories cannot be forked"
-
>
-
{{ i "git-fork" "w-4 h-4" }}
-
fork
-
</button>
-
{{ else }}
-
<a
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
-
hx-boost="true"
-
href="/{{ .FullName }}/fork"
-
>
-
{{ i "git-fork" "w-4 h-4" }}
-
fork
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</a>
-
{{ end }}
-
</div>
-
{{ end }}
···
+3 -1
api/tangled/stateclosed.go
···
// schema: sh.tangled.repo.issue.state.closed
-
const ()
const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
···
// schema: sh.tangled.repo.issue.state.closed
+
const (
+
RepoIssueStateClosedNSID = "sh.tangled.repo.issue.state.closed"
+
)
const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
+3 -1
api/tangled/stateopen.go
···
// schema: sh.tangled.repo.issue.state.open
-
const ()
const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
···
// schema: sh.tangled.repo.issue.state.open
+
const (
+
RepoIssueStateOpenNSID = "sh.tangled.repo.issue.state.open"
+
)
const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
+3 -1
api/tangled/statusclosed.go
···
// schema: sh.tangled.repo.pull.status.closed
-
const ()
const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
···
// schema: sh.tangled.repo.pull.status.closed
+
const (
+
RepoPullStatusClosedNSID = "sh.tangled.repo.pull.status.closed"
+
)
const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
+3 -1
api/tangled/statusmerged.go
···
// schema: sh.tangled.repo.pull.status.merged
-
const ()
const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
···
// schema: sh.tangled.repo.pull.status.merged
+
const (
+
RepoPullStatusMergedNSID = "sh.tangled.repo.pull.status.merged"
+
)
const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
+3 -1
api/tangled/statusopen.go
···
// schema: sh.tangled.repo.pull.status.open
-
const ()
const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
···
// schema: sh.tangled.repo.pull.status.open
+
const (
+
RepoPullStatusOpenNSID = "sh.tangled.repo.pull.status.open"
+
)
const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
+4
appview/idresolver/resolver.go
···
return r.directory.Purge(ctx, *id)
}
···
return r.directory.Purge(ctx, *id)
}
+
+
func (r *Resolver) Directory() identity.Directory {
+
return r.directory
+
}
+2 -3
appview/idresolver/resolver.go idresolver/resolver.go
···
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/carlmjohnson/versioninfo"
-
"tangled.sh/tangled.sh/core/appview/config"
)
type Resolver struct {
···
}
}
-
func RedisResolver(config config.RedisConfig) (*Resolver, error) {
-
directory, err := RedisDirectory(config.ToURL())
if err != nil {
return nil, err
}
···
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/carlmjohnson/versioninfo"
)
type Resolver struct {
···
}
}
+
func RedisResolver(redisUrl string) (*Resolver, error) {
+
directory, err := RedisDirectory(redisUrl)
if err != nil {
return nil, err
}
+30
api/tangled/reposetDefaultBranch.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.setDefaultBranch
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoSetDefaultBranchNSID = "sh.tangled.repo.setDefaultBranch"
+
)
+
+
// RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call.
+
type RepoSetDefaultBranch_Input struct {
+
DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"`
+
Repo string `json:"repo" cborgen:"repo"`
+
}
+
+
// RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch".
+
func RepoSetDefaultBranch(ctx context.Context, c util.LexClient, input *RepoSetDefaultBranch_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.setDefaultBranch", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+29
lexicons/defaultBranch.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.setDefaultBranch",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Set the default branch for a repository",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"repo",
+
"defaultBranch"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"defaultBranch": {
+
"type": "string"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+6 -1
hook/setup.go
···
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
# AUTO GENERATED BY KNOT, DO NOT MODIFY
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve
`, executablePath, config.internalApi)
return os.WriteFile(hookPath, []byte(hookContent), 0755)
···
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
# AUTO GENERATED BY KNOT, DO NOT MODIFY
+
push_options=()
+
for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do
+
option_var="GIT_PUSH_OPTION_$i"
+
push_options+=(-push-option "${!option_var}")
+
done
+
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve
`, executablePath, config.internalApi)
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+14
hook/hook.go
···
import (
"bufio"
"context"
"fmt"
"net/http"
"os"
···
"github.com/urfave/cli/v3"
)
// The hook command is nested like so:
//
// knot hook --[flags] [hook]
···
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}
···
import (
"bufio"
"context"
+
"encoding/json"
"fmt"
"net/http"
"os"
···
"github.com/urfave/cli/v3"
)
+
type HookResponse struct {
+
Messages []string `json:"messages"`
+
}
+
// The hook command is nested like so:
//
// knot hook --[flags] [hook]
···
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
+
var data HookResponse
+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+
return fmt.Errorf("failed to decode response: %w", err)
+
}
+
+
for _, message := range data.Messages {
+
fmt.Println(message)
+
}
+
return nil
}
+31
api/tangled/repoaddSecret.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.addSecret
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoAddSecretNSID = "sh.tangled.repo.addSecret"
+
)
+
+
// RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call.
+
type RepoAddSecret_Input struct {
+
Key string `json:"key" cborgen:"key"`
+
Repo string `json:"repo" cborgen:"repo"`
+
Value string `json:"value" cborgen:"value"`
+
}
+
+
// RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret".
+
func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+41
api/tangled/repolistSecrets.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.listSecrets
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoListSecretsNSID = "sh.tangled.repo.listSecrets"
+
)
+
+
// RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call.
+
type RepoListSecrets_Output struct {
+
Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"`
+
}
+
+
// RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema.
+
type RepoListSecrets_Secret struct {
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
CreatedBy string `json:"createdBy" cborgen:"createdBy"`
+
Key string `json:"key" cborgen:"key"`
+
Repo string `json:"repo" cborgen:"repo"`
+
}
+
+
// RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets".
+
func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) {
+
var out RepoListSecrets_Output
+
+
params := map[string]interface{}{}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+30
api/tangled/reporemoveSecret.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.removeSecret
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret"
+
)
+
+
// RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call.
+
type RepoRemoveSecret_Input struct {
+
Key string `json:"key" cborgen:"key"`
+
Repo string `json:"repo" cborgen:"repo"`
+
}
+
+
// RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret".
+
func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+37
lexicons/addSecret.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.addSecret",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Add a CI secret",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"repo",
+
"key",
+
"value"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"key": {
+
"type": "string",
+
"maxLength": 50,
+
"minLength": 1
+
},
+
"value": {
+
"type": "string",
+
"maxLength": 200,
+
"minLength": 1
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+67
lexicons/listSecrets.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.listSecrets",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": [
+
"repo"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"secrets"
+
],
+
"properties": {
+
"secrets": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#secret"
+
}
+
}
+
}
+
}
+
}
+
},
+
"secret": {
+
"type": "object",
+
"required": [
+
"repo",
+
"key",
+
"createdAt",
+
"createdBy"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"key": {
+
"type": "string",
+
"maxLength": 50,
+
"minLength": 1
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"createdBy": {
+
"type": "string",
+
"format": "did"
+
}
+
}
+
}
+
}
+
}
+31
lexicons/removeSecret.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.removeSecret",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Remove a CI secret",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"repo",
+
"key"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"key": {
+
"type": "string",
+
"maxLength": 50,
+
"minLength": 1
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+25
spindle/motd
···
···
+
****
+
*** ***
+
*** ** ****** **
+
** * *****
+
* ** **
+
* * * ***************
+
** ** *# **
+
* ** ** *** **
+
* * ** ** * ******
+
* ** ** * ** * *
+
** ** *** ** ** *
+
** ** * ** * *
+
** **** ** * *
+
** *** ** ** **
+
*** ** *****
+
********************
+
**
+
*
+
#**************
+
**
+
********
+
+
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle
+
+
Most API routes are under /xrpc/
+104
appview/signup/requests.go
···
···
+
package signup
+
+
// We have this extra code here for now since the xrpcclient package
+
// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth.
+
+
import (
+
"bytes"
+
"encoding/json"
+
"fmt"
+
"io"
+
"net/http"
+
"net/url"
+
)
+
+
// makePdsRequest is a helper method to make requests to the PDS service
+
func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) {
+
jsonData, err := json.Marshal(body)
+
if err != nil {
+
return nil, err
+
}
+
+
url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint)
+
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
+
if err != nil {
+
return nil, err
+
}
+
+
req.Header.Set("Content-Type", "application/json")
+
+
if useAuth {
+
req.SetBasicAuth("admin", s.config.Pds.AdminSecret)
+
}
+
+
return http.DefaultClient.Do(req)
+
}
+
+
// handlePdsError processes error responses from the PDS service
+
func (s *Signup) handlePdsError(resp *http.Response, action string) error {
+
var errorResp struct {
+
Error string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
respBody, _ := io.ReadAll(resp.Body)
+
if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" {
+
return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message)
+
}
+
+
// Fallback if we couldn't parse the error
+
return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode)
+
}
+
+
func (s *Signup) inviteCodeRequest() (string, error) {
+
body := map[string]any{"useCount": 1}
+
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true)
+
if err != nil {
+
return "", err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return "", s.handlePdsError(resp, "create invite code")
+
}
+
+
var result map[string]string
+
json.NewDecoder(resp.Body).Decode(&result)
+
return result["code"], nil
+
}
+
+
func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) {
+
parsedURL, err := url.Parse(s.config.Pds.Host)
+
if err != nil {
+
return "", fmt.Errorf("invalid PDS host URL: %w", err)
+
}
+
+
pdsDomain := parsedURL.Hostname()
+
+
body := map[string]string{
+
"email": email,
+
"handle": fmt.Sprintf("%s.%s", username, pdsDomain),
+
"password": password,
+
"inviteCode": code,
+
}
+
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false)
+
if err != nil {
+
return "", err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return "", s.handlePdsError(resp, "create account")
+
}
+
+
var result struct {
+
DID string `json:"did"`
+
}
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return "", fmt.Errorf("failed to decode create account response: %w", err)
+
}
+
+
return result.DID, nil
+
}
lexicons/addSecret.json lexicons/repo/addSecret.json
lexicons/artifact.json lexicons/repo/artifact.json
lexicons/defaultBranch.json lexicons/repo/defaultBranch.json
lexicons/listSecrets.json lexicons/repo/listSecrets.json
lexicons/removeSecret.json lexicons/repo/removeSecret.json
lexicons/spindle.json lexicons/spindle/spindle.json
+25
api/tangled/repocollaborator.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.collaborator
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoCollaboratorNSID = "sh.tangled.repo.collaborator"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{})
+
} //
+
// RECORDTYPE: RepoCollaborator
+
type RepoCollaborator struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
// repo: repo to add this user to
+
Repo string `json:"repo" cborgen:"repo"`
+
Subject string `json:"subject" cborgen:"subject"`
+
}
+36
lexicons/repo/collaborator.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.collaborator",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"subject",
+
"repo",
+
"createdAt"
+
],
+
"properties": {
+
"subject": {
+
"type": "string",
+
"format": "did"
+
},
+
"repo": {
+
"type": "string",
+
"description": "repo to add this user to",
+
"format": "at-uri"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+
+25
api/tangled/tangledstring.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.string
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
StringNSID = "sh.tangled.string"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.string", &String{})
+
} //
+
// RECORDTYPE: String
+
type String struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"`
+
Contents string `json:"contents" cborgen:"contents"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Description string `json:"description" cborgen:"description"`
+
Filename string `json:"filename" cborgen:"filename"`
+
}
+40
lexicons/string/string.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.string",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"filename",
+
"description",
+
"createdAt",
+
"contents"
+
],
+
"properties": {
+
"filename": {
+
"type": "string",
+
"maxGraphemes": 140,
+
"minGraphemes": 1
+
},
+
"description": {
+
"type": "string",
+
"maxGraphemes": 280
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"contents": {
+
"type": "string",
+
"minGraphemes": 1
+
}
+
}
+
}
+
}
+
}
+
}
+2 -3
nix/pkgs/spindle.nix
···
buildGoApplication,
modules,
sqlite-lib,
-
gitignoreSource,
}:
buildGoApplication {
pname = "spindle";
version = "0.1.0";
-
src = gitignoreSource ../..;
-
inherit modules;
doCheck = false;
···
buildGoApplication,
modules,
sqlite-lib,
+
src,
}:
buildGoApplication {
pname = "spindle";
version = "0.1.0";
+
inherit src modules;
doCheck = false;
+59
spindle/db/member.go
···
···
+
package db
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type SpindleMember struct {
+
Id int
+
Did syntax.DID // owner of the record
+
Rkey string // rkey of the record
+
Instance string
+
Subject syntax.DID // the member being added
+
Created time.Time
+
}
+
+
func AddSpindleMember(db *DB, member SpindleMember) error {
+
_, err := db.Exec(
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
+
member.Did,
+
member.Rkey,
+
member.Instance,
+
member.Subject,
+
)
+
return err
+
}
+
+
func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
+
_, err := db.Exec(
+
"delete from spindle_members where did = ? and rkey = ?",
+
owner_did,
+
rkey,
+
)
+
return err
+
}
+
+
func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
+
query :=
+
`select id, did, rkey, instance, subject, created
+
from spindle_members
+
where did = ? and rkey = ?`
+
+
var member SpindleMember
+
var createdAt string
+
err := db.QueryRow(query, did, rkey).Scan(
+
&member.Id,
+
&member.Did,
+
&member.Rkey,
+
&member.Instance,
+
&member.Subject,
+
&createdAt,
+
)
+
if err != nil {
+
return nil, err
+
}
+
+
return &member, nil
+
}
+1 -1
spindle/secrets/openbao.go
···
return ErrKeyNotFound
}
-
err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath)
if err != nil {
return fmt.Errorf("failed to delete secret from openbao: %w", err)
}
···
return ErrKeyNotFound
}
+
err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath)
if err != nil {
return fmt.Errorf("failed to delete secret from openbao: %w", err)
}
-168
appview/pages/templates/repo/settings.html
···
-
{{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }}
-
-
{{ define "repoContent" }}
-
{{ template "collaboratorSettings" . }}
-
{{ template "branchSettings" . }}
-
{{ template "dangerZone" . }}
-
{{ template "spindleSelector" . }}
-
{{ template "spindleSecrets" . }}
-
{{ end }}
-
-
{{ define "collaboratorSettings" }}
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
-
Collaborators
-
</header>
-
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
-
{{ range .Collaborators }}
-
<div id="collaborator" class="mb-2">
-
<a
-
href="/{{ didOrHandle .Did .Handle }}"
-
class="no-underline hover:underline text-black dark:text-white"
-
>
-
{{ didOrHandle .Did .Handle }}
-
</a>
-
<div>
-
<span class="text-sm text-gray-500 dark:text-gray-400">
-
{{ .Role }}
-
</span>
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
-
<form
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
-
class="group"
-
>
-
<label for="collaborator" class="dark:text-white">
-
add collaborator
-
</label>
-
<input
-
type="text"
-
id="collaborator"
-
name="collaborator"
-
required
-
class="dark:bg-gray-700 dark:text-white"
-
placeholder="enter did or handle">
-
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
-
<span>add</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</form>
-
{{ end }}
-
{{ end }}
-
-
{{ define "dangerZone" }}
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
-
<form
-
hx-confirm="Are you sure you want to delete this repository?"
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
-
class="mt-6"
-
hx-indicator="#delete-repo-spinner">
-
<label for="branch">delete repository</label>
-
<button class="btn my-2 flex items-center" type="text">
-
<span>delete</span>
-
<span id="delete-repo-spinner" class="group">
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
<span>
-
Deleting a repository is irreversible and permanent.
-
</span>
-
</form>
-
{{ end }}
-
{{ end }}
-
-
{{ define "branchSettings" }}
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
-
<label for="branch">default branch</label>
-
<div class="flex gap-2 items-center">
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
-
<option value="" disabled selected >
-
Choose a default branch
-
</option>
-
{{ range .Branches }}
-
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
-
{{ .Name }}
-
</option>
-
{{ end }}
-
</select>
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
-
<span>save</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</div>
-
</form>
-
{{ end }}
-
-
{{ define "spindleSelector" }}
-
{{ if .RepoInfo.Roles.IsOwner }}
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
-
<label for="spindle">spindle</label>
-
<div class="flex gap-2 items-center">
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
-
<option value="" selected >
-
None
-
</option>
-
{{ range .Spindles }}
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
-
{{ . }}
-
</option>
-
{{ end }}
-
</select>
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
-
<span>save</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</div>
-
</form>
-
{{ end }}
-
{{ end }}
-
-
{{ define "spindleSecrets" }}
-
{{ if $.CurrentSpindle }}
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
-
Secrets
-
</header>
-
-
<div id="secret-list" class="flex flex-col gap-2 mb-2">
-
{{ range $idx, $secret := .Secrets }}
-
{{ with $secret }}
-
<div id="secret-{{$idx}}" class="mb-2">
-
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
-
</div>
-
{{ end }}
-
{{ end }}
-
</div>
-
<form
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
-
class="mt-6"
-
hx-indicator="#add-secret-spinner">
-
<label for="key">secret key</label>
-
<input
-
type="text"
-
id="key"
-
name="key"
-
required
-
class="dark:bg-gray-700 dark:text-white"
-
placeholder="SECRET_KEY" />
-
<label for="value">secret value</label>
-
<input
-
type="text"
-
id="value"
-
name="value"
-
required
-
class="dark:bg-gray-700 dark:text-white"
-
placeholder="SECRET VALUE" />
-
-
<button class="btn my-2 flex items-center" type="text">
-
<span>add</span>
-
<span id="add-secret-spinner" class="group">
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</form>
-
{{ end }}
-
{{ end }}
···
-62
appview/db/migrations/20250305_113405.sql
···
-
-- Simplified SQLite Database Migration Script for Issues and Comments
-
-
-- Migration for issues table
-
CREATE TABLE issues_new (
-
id integer primary key autoincrement,
-
owner_did text not null,
-
repo_at text not null,
-
issue_id integer not null,
-
title text not null,
-
body text not null,
-
open integer not null default 1,
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
-
issue_at text,
-
unique(repo_at, issue_id),
-
foreign key (repo_at) references repos(at_uri) on delete cascade
-
);
-
-
-- Migrate data to new issues table
-
INSERT INTO issues_new (
-
id, owner_did, repo_at, issue_id,
-
title, body, open, created, issue_at
-
)
-
SELECT
-
id, owner_did, repo_at, issue_id,
-
title, body, open, created, issue_at
-
FROM issues;
-
-
-- Drop old issues table
-
DROP TABLE issues;
-
-
-- Rename new issues table
-
ALTER TABLE issues_new RENAME TO issues;
-
-
-- Migration for comments table
-
CREATE TABLE comments_new (
-
id integer primary key autoincrement,
-
owner_did text not null,
-
issue_id integer not null,
-
repo_at text not null,
-
comment_id integer not null,
-
comment_at text not null,
-
body text not null,
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
-
unique(issue_id, comment_id),
-
foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
-
);
-
-
-- Migrate data to new comments table
-
INSERT INTO comments_new (
-
id, owner_did, issue_id, repo_at,
-
comment_id, comment_at, body, created
-
)
-
SELECT
-
id, owner_did, issue_id, repo_at,
-
comment_id, comment_at, body, created
-
FROM comments;
-
-
-- Drop old comments table
-
DROP TABLE comments;
-
-
-- Rename new comments table
-
ALTER TABLE comments_new RENAME TO comments;
···
-66
appview/db/migrations/validate.sql
···
-
-- Validation Queries for Database Migration
-
-
-- 1. Verify Issues Table Structure
-
PRAGMA table_info(issues);
-
-
-- 2. Verify Comments Table Structure
-
PRAGMA table_info(comments);
-
-
-- 3. Check Total Row Count Consistency
-
SELECT
-
'Issues Row Count' AS check_type,
-
(SELECT COUNT(*) FROM issues) AS row_count
-
UNION ALL
-
SELECT
-
'Comments Row Count' AS check_type,
-
(SELECT COUNT(*) FROM comments) AS row_count;
-
-
-- 4. Verify Unique Constraint on Issues
-
SELECT
-
repo_at,
-
issue_id,
-
COUNT(*) as duplicate_count
-
FROM issues
-
GROUP BY repo_at, issue_id
-
HAVING duplicate_count > 1;
-
-
-- 5. Verify Foreign Key Integrity for Comments
-
SELECT
-
'Orphaned Comments' AS check_type,
-
COUNT(*) AS orphaned_count
-
FROM comments c
-
LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id
-
WHERE i.id IS NULL;
-
-
-- 6. Check Foreign Key Constraint
-
PRAGMA foreign_key_list(comments);
-
-
-- 7. Sample Data Integrity Check
-
SELECT
-
'Sample Issues' AS check_type,
-
repo_at,
-
issue_id,
-
title,
-
created
-
FROM issues
-
LIMIT 5;
-
-
-- 8. Sample Comments Data Integrity Check
-
SELECT
-
'Sample Comments' AS check_type,
-
repo_at,
-
issue_id,
-
comment_id,
-
body,
-
created
-
FROM comments
-
LIMIT 5;
-
-
-- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness)
-
SELECT
-
issue_id,
-
comment_id,
-
COUNT(*) as duplicate_count
-
FROM comments
-
GROUP BY issue_id, comment_id
-
HAVING duplicate_count > 1;
···
+14
nix/modules/appview.nix
···
default = "00000000000000000000000000000000";
description = "Cookie secret";
};
};
};
···
ListenStream = "0.0.0.0:${toString cfg.port}";
ExecStart = "${cfg.package}/bin/appview";
Restart = "always";
};
environment = {
···
default = "00000000000000000000000000000000";
description = "Cookie secret";
};
+
environmentFile = mkOption {
+
type = with types; nullOr path;
+
default = null;
+
example = "/etc/tangled-appview.env";
+
description = ''
+
Additional environment file as defined in {manpage}`systemd.exec(5)`.
+
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
+
passed to the service without makeing them world readable in the
+
nix store.
+
+
'';
+
};
};
};
···
ListenStream = "0.0.0.0:${toString cfg.port}";
ExecStart = "${cfg.package}/bin/appview";
Restart = "always";
+
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
};
environment = {
+1 -1
.air/appview.toml
···
exclude_regex = [".*_templ.go"]
include_ext = ["go", "templ", "html", "css"]
-
exclude_dir = ["target", "atrium"]
···
exclude_regex = [".*_templ.go"]
include_ext = ["go", "templ", "html", "css"]
+
exclude_dir = ["target", "atrium", "nix"]
+1 -1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
···
{{ $color = "text-gray-600 dark:text-gray-500" }}
{{ else if eq $kind "timeout" }}
{{ $icon = "clock-alert" }}
-
{{ $color = "text-orange-400 dark:text-orange-300" }}
{{ else }}
{{ $icon = "x" }}
{{ $color = "text-red-600 dark:text-red-500" }}
···
{{ $color = "text-gray-600 dark:text-gray-500" }}
{{ else if eq $kind "timeout" }}
{{ $icon = "clock-alert" }}
+
{{ $color = "text-orange-400 dark:text-orange-500" }}
{{ else }}
{{ $icon = "x" }}
{{ $color = "text-red-600 dark:text-red-500" }}
+1 -1
tailwind.config.js
···
css: {
maxWidth: "none",
pre: {
-
"@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {},
},
code: {
"@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
···
css: {
maxWidth: "none",
pre: {
+
"@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {},
},
code: {
"@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+1 -1
nix/pkgs/appview-static-files.nix
···
cp -rf ${lucide-src}/*.svg icons/
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
# for whatever reason (produces broken css), so we are doing this instead
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
···
cp -rf ${lucide-src}/*.svg icons/
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
# for whatever reason (produces broken css), so we are doing this instead
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+3 -1
log/log.go
···
// NewHandler sets up a new slog.Handler with the service name
// as an attribute
func NewHandler(name string) slog.Handler {
-
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})
var attrs []slog.Attr
attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
···
// NewHandler sets up a new slog.Handler with the service name
// as an attribute
func NewHandler(name string) slog.Handler {
+
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+
Level: slog.LevelDebug,
+
})
var attrs []slog.Attr
attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+1 -1
cmd/punchcardPopulate/main.go
···
)
func main() {
-
db, err := sql.Open("sqlite3", "./appview.db")
if err != nil {
log.Fatal("Failed to open database:", err)
}
···
)
func main() {
+
db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1")
if err != nil {
log.Fatal("Failed to open database:", err)
}
+1 -1
spindle/secrets/sqlite.go
···
}
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
-
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
···
}
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
+
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
+14 -9
knotserver/db/init.go
···
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
···
}
func Setup(dbPath string) (*DB, error) {
-
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
if err != nil {
return nil, err
}
-
_, err = db.Exec(`
-
pragma journal_mode = WAL;
-
pragma synchronous = normal;
-
pragma temp_store = memory;
-
pragma mmap_size = 30000000000;
-
pragma page_size = 32768;
-
pragma auto_vacuum = incremental;
-
pragma busy_timeout = 5000;
create table if not exists known_dids (
did text primary key
);
···
import (
"database/sql"
+
"strings"
_ "github.com/mattn/go-sqlite3"
)
···
}
func Setup(dbPath string) (*DB, error) {
+
// https://github.com/mattn/go-sqlite3#connection-string
+
opts := []string{
+
"_foreign_keys=1",
+
"_journal_mode=WAL",
+
"_synchronous=NORMAL",
+
"_auto_vacuum=incremental",
+
}
+
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
if err != nil {
return nil, err
}
+
// NOTE: If any other migration is added here, you MUST
+
// copy the pattern in appview: use a single sql.Conn
+
// for every migration.
+
_, err = db.Exec(`
create table if not exists known_dids (
did text primary key
);
+14 -9
spindle/db/db.go
···
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
···
}
func Make(dbPath string) (*DB, error) {
-
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
if err != nil {
return nil, err
}
-
_, err = db.Exec(`
-
pragma journal_mode = WAL;
-
pragma synchronous = normal;
-
pragma temp_store = memory;
-
pragma mmap_size = 30000000000;
-
pragma page_size = 32768;
-
pragma auto_vacuum = incremental;
-
pragma busy_timeout = 5000;
create table if not exists _jetstream (
id integer primary key autoincrement,
last_time_us integer not null
···
import (
"database/sql"
+
"strings"
_ "github.com/mattn/go-sqlite3"
)
···
}
func Make(dbPath string) (*DB, error) {
+
// https://github.com/mattn/go-sqlite3#connection-string
+
opts := []string{
+
"_foreign_keys=1",
+
"_journal_mode=WAL",
+
"_synchronous=NORMAL",
+
"_auto_vacuum=incremental",
+
}
+
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
if err != nil {
return nil, err
}
+
// NOTE: If any other migration is added here, you MUST
+
// copy the pattern in appview: use a single sql.Conn
+
// for every migration.
+
_, err = db.Exec(`
create table if not exists _jetstream (
id integer primary key autoincrement,
last_time_us integer not null
+12
.prettierrc.json
···
···
+
{
+
"overrides": [
+
{
+
"files": ["*.html"],
+
"options": {
+
"parser": "go-template"
+
}
+
}
+
],
+
"bracketSameLine": true,
+
"htmlWhitespaceSensitivity": "ignore"
+
}
-16
.zed/settings.json
···
-
// Folder-specific settings
-
//
-
// For a full list of overridable settings, and general information on folder-specific settings,
-
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
-
{
-
"languages": {
-
"HTML": {
-
"prettier": {
-
"format_on_save": false,
-
"allowed": true,
-
"parser": "go-template",
-
"plugins": ["prettier-plugin-go-template"]
-
}
-
}
-
}
-
}
···
+37 -83
appview/pages/templates/layouts/footer.html
···
{{ define "layouts/footer" }}
-
<div
-
class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm">
-
<div class="container mx-auto max-w-7xl px-4">
-
<div
-
class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8">
-
<div class="mb-4 md:mb-0">
-
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
-
tangled
-
<sub>alpha</sub>
-
</a>
-
</div>
-
-
{{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }}
-
{{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }}
-
{{ $iconStyle := "w-4 h-4 flex-shrink-0" }}
-
<div
-
class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1">
-
<div class="flex flex-col gap-1">
-
<div class="{{ $headerStyle }}">legal</div>
-
<a href="/terms" class="{{ $linkStyle }}">
-
{{ i "file-text" $iconStyle }} terms of service
-
</a>
-
<a href="/privacy" class="{{ $linkStyle }}">
-
{{ i "shield" $iconStyle }} privacy policy
-
</a>
-
</div>
-
<div class="flex flex-col gap-1">
-
<div class="{{ $headerStyle }}">resources</div>
-
<a
-
href="https://blog.tangled.sh"
-
class="{{ $linkStyle }}"
-
target="_blank"
-
rel="noopener noreferrer">
-
{{ i "book-open" $iconStyle }} blog
-
</a>
-
<a
-
href="https://tangled.sh/@tangled.sh/core/tree/master/docs"
-
class="{{ $linkStyle }}">
-
{{ i "book" $iconStyle }} docs
-
</a>
-
<a
-
href="https://tangled.sh/@tangled.sh/core"
-
class="{{ $linkStyle }}">
-
{{ i "code" $iconStyle }} source
-
</a>
-
</div>
-
<div class="flex flex-col gap-1">
-
<div class="{{ $headerStyle }}">social</div>
-
<a
-
href="https://chat.tangled.sh"
-
class="{{ $linkStyle }}"
-
target="_blank"
-
rel="noopener noreferrer">
-
{{ i "message-circle" $iconStyle }} discord
-
</a>
-
<a
-
href="https://web.libera.chat/#tangled"
-
class="{{ $linkStyle }}"
-
target="_blank"
-
rel="noopener noreferrer">
-
{{ i "hash" $iconStyle }} irc
-
</a>
-
<a
-
href="https://bsky.app/profile/tangled.sh"
-
class="{{ $linkStyle }}"
-
target="_blank"
-
rel="noopener noreferrer">
-
{{ template "user/fragments/bluesky" $iconStyle }} bluesky
-
</a>
-
</div>
-
<div class="flex flex-col gap-1">
-
<div class="{{ $headerStyle }}">contact</div>
-
<a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">
-
{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh
-
</a>
-
<a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">
-
{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh
-
</a>
-
</div>
</div>
-
<div class="text-center lg:text-right flex-shrink-0">
-
<div class="text-xs">
-
&copy; 2025 Tangled Labs Oy. All rights reserved.
-
</div>
</div>
</div>
</div>
</div>
{{ end }}
···
{{ define "layouts/footer" }}
+
<div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm">
+
<div class="container mx-auto max-w-7xl px-4">
+
<div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8">
+
<div class="mb-4 md:mb-0">
+
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
+
tangled<sub>alpha</sub>
+
</a>
+
</div>
+
{{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }}
+
{{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }}
+
{{ $iconStyle := "w-4 h-4 flex-shrink-0" }}
+
<div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1">
+
<div class="flex flex-col gap-1">
+
<div class="{{ $headerStyle }}">legal</div>
+
<a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a>
+
<a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a>
+
</div>
+
<div class="flex flex-col gap-1">
+
<div class="{{ $headerStyle }}">resources</div>
+
<a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a>
+
<a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a>
+
<a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a>
+
</div>
+
<div class="flex flex-col gap-1">
+
<div class="{{ $headerStyle }}">social</div>
+
<a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a>
+
<a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a>
+
<a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a>
</div>
+
<div class="flex flex-col gap-1">
+
<div class="{{ $headerStyle }}">contact</div>
+
<a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a>
+
<a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a>
</div>
</div>
+
+
<div class="text-center lg:text-right flex-shrink-0">
+
<div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div>
+
</div>
</div>
</div>
+
</div>
{{ end }}
+9 -16
appview/pages/templates/repo/compare/new.html
···
{{ define "title" }}
-
compare refs on
-
{{ .RepoInfo.FullName }}
{{ end }}
{{ define "repoContent" }}
···
{{ define "repoAfter" }}
{{ $brs := take .Branches 5 }}
{{ if $brs }}
-
<section
-
class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto">
<div class="flex flex-col items-center">
<p class="text-center text-black dark:text-white">
-
Recently updated branches in this repository:
</p>
-
<div
-
class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2">
-
{{ range $br := $brs }}
-
<a
-
href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}"
-
class="no-underline hover:no-underline">
<div class="flex items-center justify-between p-2">
{{ $br.Name }}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ template "repo/fragments/time" $br.Commit.Committer.When }}
-
</span>
</div>
</a>
-
{{ end }}
-
</div>
</div>
</section>
{{ end }}
···
{{ define "title" }}
+
compare refs on {{ .RepoInfo.FullName }}
{{ end }}
{{ define "repoContent" }}
···
{{ define "repoAfter" }}
{{ $brs := take .Branches 5 }}
{{ if $brs }}
+
<section class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto">
<div class="flex flex-col items-center">
<p class="text-center text-black dark:text-white">
+
Recently updated branches in this repository:
</p>
+
<div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2">
+
{{ range $br := $brs }}
+
<a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline">
<div class="flex items-center justify-between p-2">
{{ $br.Name }}
+
<span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span>
</div>
</a>
+
{{ end }}
+
</div>
</div>
</section>
{{ end }}
+13 -27
appview/pages/templates/repo/fragments/artifact.html
···
{{ define "repo/fragments/artifact" }}
-
{{ $unique := .Artifact.BlobCid.String }}
-
<div
-
id="artifact-{{ $unique }}"
-
class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
-
<div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]">
-
{{ i "box" "w-4 h-4" }}
-
<a
-
href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}"
-
class="no-underline hover:no-underline">
-
{{ .Artifact.Name }}
-
</a>
-
<span class="text-gray-500 dark:text-gray-400 pl-2 text-sm">
-
{{ byteFmt .Artifact.Size }}
-
</span>
-
</div>
-
<div
-
id="right-side"
-
class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm">
-
<span class="hidden md:inline">
-
{{ template "repo/fragments/time" .Artifact.CreatedAt }}
-
</span>
-
<span class=" md:hidden">
-
{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}
-
</span>
<span class="select-none after:content-['ยท'] hidden md:inline"></span>
-
<span class="truncate max-w-[100px] hidden md:inline">
-
{{ .Artifact.MimeType }}
-
</span>
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }}
<button
···
{{ define "repo/fragments/artifact" }}
+
{{ $unique := .Artifact.BlobCid.String }}
+
<div id="artifact-{{ $unique }}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
+
<div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]">
+
{{ i "box" "w-4 h-4" }}
+
<a href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}" class="no-underline hover:no-underline">
+
{{ .Artifact.Name }}
+
</a>
+
<span class="text-gray-500 dark:text-gray-400 pl-2 text-sm">{{ byteFmt .Artifact.Size }}</span>
+
</div>
+
<div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm">
+
<span class="hidden md:inline">{{ template "repo/fragments/time" .Artifact.CreatedAt }}</span>
+
<span class=" md:hidden">{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}</span>
<span class="select-none after:content-['ยท'] hidden md:inline"></span>
+
<span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }}
<button
+54 -63
appview/pages/templates/repo/fragments/compareForm.html
···
{{ define "repo/fragments/compareForm" }}
-
<div id="compare-select">
-
<h2 class="font-bold text-sm mb-2 uppercase dark:text-white">
-
Compare changes
-
</h2>
-
<p>Choose any two refs to compare.</p>
-
<form id="compare-form" class="flex items-center gap-2 py-4">
-
<div>
-
<span class="hidden md:inline">base:</span>
-
{{ block "dropdown" (list $ "base" $.Base) }}{{ end }}
-
</div>
-
<span class="flex-shrink-0">
-
{{ i "arrow-left" "w-4 h-4" }}
-
</span>
-
<div>
-
<span class="hidden md:inline">compare:</span>
-
{{ block "dropdown" (list $ "head" $.Head) }}{{ end }}
-
</div>
-
<button
-
id="compare-button"
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed"
-
type="button"
-
hx-boost="true"
-
onclick="
const base = document.getElementById('base-select').value;
const head = document.getElementById('head-select').value;
-
window.location.href = `/{{ $.RepoInfo.FullName }}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`;
">
-
go
-
</button>
-
</form>
-
</div>
-
<script>
-
const baseSelect = document.getElementById("base-select");
-
const headSelect = document.getElementById("head-select");
-
const compareButton = document.getElementById("compare-button");
-
function toggleButtonState() {
-
compareButton.disabled = baseSelect.value === headSelect.value;
-
}
-
baseSelect.addEventListener("change", toggleButtonState);
-
headSelect.addEventListener("change", toggleButtonState);
-
// Run once on page load
-
toggleButtonState();
-
</script>
{{ end }}
{{ define "dropdown" }}
-
{{ $root := index . 0 }}
-
{{ $name := index . 1 }}
-
{{ $default := index . 2 }}
-
<select
-
name="{{ $name }}"
-
id="{{ $name }}-select"
-
class="p-1 border max-w-32 md:max-w-64 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
<optgroup label="branches ({{ len $root.Branches }})" class="bold text-sm">
{{ range $root.Branches }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{ if eq .Reference.Name $default }}selected{{ end }}>
{{ .Reference.Name }}
</option>
{{ end }}
</optgroup>
-
<optgroup label="tags ({{ len $root.Tags }})" class="bold text-sm">
-
{{ range $root.Tags }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{ if eq .Reference.Name $default }}selected{{ end }}>
-
{{ .Reference.Name }}
-
</option>
-
{{ else }}
-
<option class="py-1" disabled>no tags found</option>
-
{{ end }}
-
</optgroup>
</select>
{{ end }}
···
{{ define "repo/fragments/compareForm" }}
+
<div id="compare-select">
+
<h2 class="font-bold text-sm mb-2 uppercase dark:text-white">
+
Compare changes
+
</h2>
+
<p>Choose any two refs to compare.</p>
+
<form id="compare-form" class="flex items-center gap-2 py-4">
+
<div>
+
<span class="hidden md:inline">base:</span>
+
{{ block "dropdown" (list $ "base" $.Base) }} {{ end }}
+
</div>
+
<span class="flex-shrink-0">
+
{{ i "arrow-left" "w-4 h-4" }}
+
</span>
+
<div>
+
<span class="hidden md:inline">compare:</span>
+
{{ block "dropdown" (list $ "head" $.Head) }} {{ end }}
+
</div>
+
<button
+
id="compare-button"
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed"
+
type="button"
+
hx-boost="true"
+
onclick="
const base = document.getElementById('base-select').value;
const head = document.getElementById('head-select').value;
+
window.location.href = `/{{$.RepoInfo.FullName}}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`;
">
+
go
+
</button>
+
</form>
+
</div>
+
<script>
+
const baseSelect = document.getElementById('base-select');
+
const headSelect = document.getElementById('head-select');
+
const compareButton = document.getElementById('compare-button');
+
function toggleButtonState() {
+
compareButton.disabled = baseSelect.value === headSelect.value;
+
}
+
baseSelect.addEventListener('change', toggleButtonState);
+
headSelect.addEventListener('change', toggleButtonState);
+
// Run once on page load
+
toggleButtonState();
+
</script>
{{ end }}
{{ define "dropdown" }}
+
{{ $root := index . 0 }}
+
{{ $name := index . 1 }}
+
{{ $default := index . 2 }}
+
<select name="{{$name}}" id="{{$name}}-select" class="p-1 border max-w-32 md:max-w-64 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
<optgroup label="branches ({{ len $root.Branches }})" class="bold text-sm">
{{ range $root.Branches }}
+
<option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}>
{{ .Reference.Name }}
</option>
{{ end }}
</optgroup>
+
<optgroup label="tags ({{ len $root.Tags }})" class="bold text-sm">
+
{{ range $root.Tags }}
+
<option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}>
+
{{ .Reference.Name }}
+
</option>
+
{{ else }}
+
<option class="py-1" disabled>no tags found</option>
+
{{ end }}
+
</optgroup>
</select>
{{ end }}
+6 -14
appview/pages/templates/repo/fragments/diffOpts.html
···
{{ define "repo/fragments/diffOpts" }}
-
<section
-
class="flex flex-col gap-2 overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
<strong class="text-sm uppercase dark:text-gray-200">options</strong>
{{ $active := "unified" }}
{{ if .Split }}
{{ $active = "split" }}
{{ end }}
{{ $values := list "unified" "split" }}
-
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }}
-
{{ end }}
</section>
{{ end }}
···
{{ $name := .Name }}
{{ $all := .Values }}
{{ $active := .Active }}
-
<div
-
class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
{{ range $index, $value := $all }}
{{ $isActive := eq $value $active }}
-
<a
-
href="?{{ $name }}={{ $value }}"
-
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }}
-
{{ $activeTab }}
-
{{ else }}
-
{{ $inactiveTab }}
-
{{ end }}">
-
{{ $value }}
</a>
{{ end }}
</div>
···
{{ define "repo/fragments/diffOpts" }}
+
<section class="flex flex-col gap-2 overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
<strong class="text-sm uppercase dark:text-gray-200">options</strong>
{{ $active := "unified" }}
{{ if .Split }}
{{ $active = "split" }}
{{ end }}
{{ $values := list "unified" "split" }}
+
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }}
</section>
{{ end }}
···
{{ $name := .Name }}
{{ $all := .Values }}
{{ $active := .Active }}
+
<div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
{{ range $index, $value := $all }}
{{ $isActive := eq $value $active }}
+
<a href="?{{ $name }}={{ $value }}"
+
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
+
{{ $value }}
</a>
{{ end }}
</div>
+5 -16
appview/pages/templates/repo/fragments/diffStatPill.html
···
{{ define "repo/fragments/diffStatPill" }}
<div class="flex items-center font-mono text-sm">
{{ if and .Insertions .Deletions }}
-
<span
-
class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">
-
+{{ .Insertions }}
-
</span>
-
<span
-
class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">
-
-{{ .Deletions }}
-
</span>
{{ else if .Insertions }}
-
<span
-
class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">
-
+{{ .Insertions }}
-
</span>
{{ else if .Deletions }}
-
<span
-
class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">
-
-{{ .Deletions }}
-
</span>
{{ end }}
</div>
{{ end }}
···
{{ define "repo/fragments/diffStatPill" }}
<div class="flex items-center font-mono text-sm">
{{ if and .Insertions .Deletions }}
+
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
+
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
{{ else if .Insertions }}
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
{{ else if .Deletions }}
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
{{ end }}
</div>
{{ end }}
+
+28 -21
appview/pages/templates/repo/fragments/reaction.html
···
{{ define "repo/fragments/reaction" }}
-
<button
-
id="reactIndi-{{ .Kind }}"
-
class="flex justify-center items-center min-w-8 min-h-8 rounded border
leading-4 px-3 gap-1
{{ if eq .Count 0 }}
-
hidden
-
{{ end }}
{{ if .IsReacted }}
-
bg-sky-100 border-sky-400 dark:bg-sky-900 dark:border-sky-500
-
{{ else }}
-
border-gray-200 hover:bg-gray-50 hover:border-gray-300
-
dark:border-gray-700 dark:hover:bg-gray-700 dark:hover:border-gray-600
-
{{ end }}
"
-
{{ if .IsReacted }}
-
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
-
{{ else }}
-
hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
-
{{ end }}
-
hx-swap="outerHTML"
-
hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})"
-
hx-disabled-elt="this">
-
<span>{{ .Kind }}</span>
-
<span>{{ .Count }}</span>
-
</button>
{{ end }}
···
{{ define "repo/fragments/reaction" }}
+
<button
+
id="reactIndi-{{ .Kind }}"
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border
leading-4 px-3 gap-1
{{ if eq .Count 0 }}
+
hidden
+
{{ end }}
{{ if .IsReacted }}
+
bg-sky-100
+
border-sky-400
+
dark:bg-sky-900
+
dark:border-sky-500
+
{{ else }}
+
border-gray-200
+
hover:bg-gray-50
+
hover:border-gray-300
+
dark:border-gray-700
+
dark:hover:bg-gray-700
+
dark:hover:border-gray-600
+
{{ end }}
"
+
{{ if .IsReacted }}
+
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
+
{{ else }}
+
hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
+
{{ end }}
+
hx-swap="outerHTML"
+
hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})"
+
hx-disabled-elt="this"
+
>
+
<span>{{ .Kind }}</span> <span>{{ .Count }}</span>
+
</button>
{{ end }}
+24 -18
appview/pages/templates/repo/fragments/reactionsPopUp.html
···
{{ define "repo/fragments/reactionsPopUp" }}
-
<details id="reactionsPopUp" class="relative inline-block">
-
<summary
-
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
hover:bg-gray-50
hover:border-gray-300
dark:hover:bg-gray-700
dark:hover:border-gray-600
-
cursor-pointer list-none">
-
{{ i "smile" "size-4" }}
-
</summary>
-
<div
-
class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg">
-
{{ range $kind := . }}
-
<button
-
id="reactBtn-{{ $kind }}"
-
class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700"
-
hx-on:click="this.parentElement.parentElement.removeAttribute('open')">
-
{{ $kind }}
-
</button>
-
{{ end }}
-
</div>
-
</details>
{{ end }}
···
{{ define "repo/fragments/reactionsPopUp" }}
+
<details
+
id="reactionsPopUp"
+
class="relative inline-block"
+
>
+
<summary
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
hover:bg-gray-50
hover:border-gray-300
dark:hover:bg-gray-700
dark:hover:border-gray-600
+
cursor-pointer list-none"
+
>
+
{{ i "smile" "size-4" }}
+
</summary>
+
<div
+
class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg"
+
>
+
{{ range $kind := . }}
+
<button
+
id="reactBtn-{{ $kind }}"
+
class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700"
+
hx-on:click="this.parentElement.parentElement.removeAttribute('open')"
+
>
+
{{ $kind }}
+
</button>
+
{{ end }}
+
</div>
+
</details>
{{ end }}
+23 -22
appview/pages/templates/repo/fragments/repoStar.html
···
{{ define "repo/fragments/repoStar" }}
-
<button
-
id="starBtn"
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
-
{{ if .IsStarred }}
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ else }}
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ end }}
-
hx-trigger="click"
-
hx-target="this"
-
hx-swap="outerHTML"
-
hx-disabled-elt="#starBtn">
-
{{ if .IsStarred }}
-
{{ i "star" "w-4 h-4 fill-current" }}
-
{{ else }}
-
{{ i "star" "w-4 h-4" }}
-
{{ end }}
-
<span class="text-sm">
-
{{ .Stats.StarCount }}
-
</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
{{ end }}
···
{{ define "repo/fragments/repoStar" }}
+
<button
+
id="starBtn"
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
+
{{ if .IsStarred }}
+
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
+
{{ else }}
+
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
+
{{ end }}
+
hx-trigger="click"
+
hx-target="this"
+
hx-swap="outerHTML"
+
hx-disabled-elt="#starBtn"
+
>
+
{{ if .IsStarred }}
+
{{ i "star" "w-4 h-4 fill-current" }}
+
{{ else }}
+
{{ i "star" "w-4 h-4" }}
+
{{ end }}
+
<span class="text-sm">
+
{{ .Stats.StarCount }}
+
</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
{{ end }}
+58 -113
appview/pages/templates/repo/fragments/splitDiff.html
···
{{ define "repo/fragments/splitDiff" }}
-
{{ $name := .Id }}
-
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
-
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
-
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
-
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
-
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
-
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
-
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
-
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
-
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
-
<pre
-
class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}
-
<div
-
class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">
-
&middot;&middot;&middot;
-
</div>
-
{{- range .LeftLines -}}
-
{{- if .IsEmpty -}}
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
-
<span aria-hidden="true" class="invisible">
-
{{ .LineNumber }}
-
</span>
-
</div>
-
<div class="{{ $opStyle }}">
-
<span aria-hidden="true" class="invisible">{{ .Op.String }}</span>
-
</div>
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
-
</div>
-
{{- else if eq .Op.String "-" -}}
-
<div
-
class="{{ $delStyle }} {{ $containerStyle }}"
-
id="{{ $name }}-O{{ .LineNumber }}">
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
-
<a
-
class="{{ $linkStyle }}"
-
href="#{{ $name }}-O{{ .LineNumber }}">
-
{{ .LineNumber }}
-
</a>
-
</div>
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
-
<div class="px-2">{{ .Content }}</div>
-
</div>
-
{{- else if eq .Op.String " " -}}
-
<div
-
class="{{ $ctxStyle }} {{ $containerStyle }}"
-
id="{{ $name }}-O{{ .LineNumber }}">
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
-
<a
-
class="{{ $linkStyle }}"
-
href="#{{ $name }}-O{{ .LineNumber }}">
-
{{ .LineNumber }}
-
</a>
-
</div>
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
-
<div class="px-2">{{ .Content }}</div>
-
</div>
-
{{- end -}}
-
{{- end -}}
-
{{- end -}}
-
</div></div></pre>
-
<pre
-
class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}
-
<div
-
class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">
-
&middot;&middot;&middot;
-
</div>
-
{{- range .RightLines -}}
-
{{- if .IsEmpty -}}
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
-
<span aria-hidden="true" class="invisible">
-
{{ .LineNumber }}
-
</span>
-
</div>
-
<div class="{{ $opStyle }}">
-
<span aria-hidden="true" class="invisible">{{ .Op.String }}</span>
-
</div>
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
-
</div>
-
{{- else if eq .Op.String "+" -}}
-
<div
-
class="{{ $addStyle }} {{ $containerStyle }}"
-
id="{{ $name }}-N{{ .LineNumber }}">
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
-
<a
-
class="{{ $linkStyle }}"
-
href="#{{ $name }}-N{{ .LineNumber }}">
-
{{ .LineNumber }}
-
</a>
-
</div>
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
-
<div class="px-2">{{ .Content }}</div>
-
</div>
-
{{- else if eq .Op.String " " -}}
-
<div
-
class="{{ $ctxStyle }} {{ $containerStyle }}"
-
id="{{ $name }}-N{{ .LineNumber }}">
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
-
<a
-
class="{{ $linkStyle }}"
-
href="#{{ $name }}-N{{ .LineNumber }}">
-
{{ .LineNumber }}
-
</a>
-
</div>
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
-
<div class="px-2">{{ .Content }}</div>
-
</div>
-
{{- end -}}
-
{{- end -}}
-
{{- end -}}</div></div></pre>
-
</div>
{{ end }}
···
{{ define "repo/fragments/splitDiff" }}
+
{{ $name := .Id }}
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
+
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
+
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
+
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
+
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
+
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
+
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
+
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
+
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
+
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div>
+
{{- range .LeftLines -}}
+
{{- if .IsEmpty -}}
+
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
+
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
+
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
+
</div>
+
{{- else if eq .Op.String "-" -}}
+
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
+
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Content }}</div>
+
</div>
+
{{- else if eq .Op.String " " -}}
+
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
+
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Content }}</div>
+
</div>
+
{{- end -}}
+
{{- end -}}
+
{{- end -}}</div></div></pre>
+
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div>
+
{{- range .RightLines -}}
+
{{- if .IsEmpty -}}
+
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
+
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
+
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
+
</div>
+
{{- else if eq .Op.String "+" -}}
+
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
+
<div class="px-2" >{{ .Content }}</div>
+
</div>
+
{{- else if eq .Op.String " " -}}
+
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Content }}</div>
+
</div>
+
{{- end -}}
+
{{- end -}}
+
{{- end -}}</div></div></pre>
+
</div>
{{ end }}
+45 -79
appview/pages/templates/repo/fragments/unifiedDiff.html
···
{{ define "repo/fragments/unifiedDiff" }}
-
{{ $name := .Id }}
-
<pre
-
class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}
-
<div
-
class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">
-
&middot;&middot;&middot;
-
</div>
-
{{- $oldStart := .OldPosition -}}
-
{{- $newStart := .NewPosition -}}
-
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
-
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
-
{{- $lineNrSepStyle1 := "" -}}
-
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
-
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
-
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
-
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
-
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
-
{{- range .Lines -}}
-
{{- if eq .Op.String "+" -}}
-
<div
-
class="{{ $addStyle }} {{ $containerStyle }}"
-
id="{{ $name }}-N{{ $newStart }}">
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}">
-
<span aria-hidden="true" class="invisible">{{ $newStart }}</span>
-
</div>
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}">
-
<a class="{{ $linkStyle }}" href="#{{ $name }}-N{{ $newStart }}">
-
{{ $newStart }}
-
</a>
-
</div>
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
-
<div class="px-2">{{ .Line }}</div>
-
</div>
-
{{- $newStart = add64 $newStart 1 -}}
-
{{- end -}}
-
{{- if eq .Op.String "-" -}}
-
<div
-
class="{{ $delStyle }} {{ $containerStyle }}"
-
id="{{ $name }}-O{{ $oldStart }}">
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}">
-
<a class="{{ $linkStyle }}" href="#{{ $name }}-O{{ $oldStart }}">
-
{{ $oldStart }}
-
</a>
-
</div>
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}">
-
<span aria-hidden="true" class="invisible">{{ $oldStart }}</span>
-
</div>
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
-
<div class="px-2">{{ .Line }}</div>
-
</div>
-
{{- $oldStart = add64 $oldStart 1 -}}
-
{{- end -}}
-
{{- if eq .Op.String " " -}}
-
<div
-
class="{{ $ctxStyle }} {{ $containerStyle }}"
-
id="{{ $name }}-O{{ $oldStart }}-N{{ $newStart }}">
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}">
-
<a
-
class="{{ $linkStyle }}"
-
href="#{{ $name }}-O{{ $oldStart }}-N{{ $newStart }}">
-
{{ $oldStart }}
-
</a>
-
</div>
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}">
-
<a
-
class="{{ $linkStyle }}"
-
href="#{{ $name }}-O{{ $oldStart }}-N{{ $newStart }}">
-
{{ $newStart }}
-
</a>
-
</div>
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
-
<div class="px-2">{{ .Line }}</div>
-
</div>
-
{{- $newStart = add64 $newStart 1 -}}
-
{{- $oldStart = add64 $oldStart 1 -}}
-
{{- end -}}
-
{{- end -}}
-
{{- end -}}</div></div></pre>
{{ end }}
···
{{ define "repo/fragments/unifiedDiff" }}
+
{{ $name := .Id }}
+
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div>
+
{{- $oldStart := .OldPosition -}}
+
{{- $newStart := .NewPosition -}}
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
+
{{- $lineNrSepStyle1 := "" -}}
+
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
+
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
+
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
+
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
+
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
+
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
+
{{- range .Lines -}}
+
{{- if eq .Op.String "+" -}}
+
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String "-" -}}
+
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String " " -}}
+
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- end -}}
+
{{- end -}}</div></div></pre>
{{ end }}
+
+12 -21
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
{{ define "repo/pipelines/fragments/logBlock" }}
-
<div id="lines" hx-swap-oob="beforeend">
-
<details
-
id="step-{{ .Id }}"
-
{{ if not .Collapsed }}open{{ end }}
-
class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
-
<summary
-
class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
-
<div class="group-open:hidden flex items-center gap-1">
-
{{ i "chevron-right" "w-4 h-4" }}
-
{{ .Name }}
-
</div>
-
<div class="hidden group-open:flex items-center gap-1">
-
{{ i "chevron-down" "w-4 h-4" }}
-
{{ .Name }}
-
</div>
-
</summary>
-
<div class="font-mono whitespace-pre overflow-x-auto px-2">
-
<div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div>
-
<div id="step-body-{{ .Id }}"></div>
</div>
-
</details>
-
</div>
{{ end }}
···
{{ define "repo/pipelines/fragments/logBlock" }}
+
<div id="lines" hx-swap-oob="beforeend">
+
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
+
<summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
+
<div class="group-open:hidden flex items-center gap-1">
+
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
</div>
+
<div class="hidden group-open:flex items-center gap-1">
+
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
+
</div>
+
</summary>
+
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
+
</details>
+
</div>
{{ end }}
+63 -62
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
{{ $isUpToDate := .ResubmitCheck.No }}
<div class="relative w-fit">
-
<div id="actions-{{ $roundNumber }}" class="flex flex-wrap gap-2">
-
<button
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
-
hx-target="#actions-{{ $roundNumber }}"
-
hx-swap="outerHtml"
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
-
{{ i "message-square-plus" "w-4 h-4" }}
-
<span>comment</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
-
{{ $disabled := "" }}
-
{{ if $isConflicted }}
-
{{ $disabled = "disabled" }}
-
{{ end }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
-
hx-swap="none"
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
-
class="btn p-2 flex items-center gap-2 group"
-
{{ $disabled }}>
-
{{ i "git-merge" "w-4 h-4" }}
-
<span>merge{{ if $stackCount }}{{ $stackCount }}{{ end }}</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
-
{{ end }}
-
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
-
{{ $disabled := "" }}
-
{{ if $isUpToDate }}
-
{{ $disabled = "disabled" }}
{{ end }}
-
<button
-
id="resubmitBtn"
-
{{ if not .Pull.IsPatchBased }}
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
-
{{ else }}
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
-
hx-target="#actions-{{ $roundNumber }}" hx-swap="outerHtml"
{{ end }}
-
hx-disabled-elt="#resubmitBtn"
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group"
-
{{ $disabled }}
-
{{ if $disabled }}
-
title="Update this branch to resubmit this pull request"
-
{{ else }}
-
title="Resubmit this pull request"
-
{{ end }}>
-
{{ i "rotate-ccw" "w-4 h-4" }}
-
<span>resubmit</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
-
<button
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
hx-swap="none"
class="btn p-2 flex items-center gap-2 group">
-
{{ i "ban" "w-4 h-4" }}
-
<span>close</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
-
{{ end }}
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
-
<button
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
hx-swap="none"
class="btn p-2 flex items-center gap-2 group">
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
-
<span>reopen</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
-
{{ end }}
</div>
</div>
{{ end }}
···
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
{{ $isUpToDate := .ResubmitCheck.No }}
<div class="relative w-fit">
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
+
<button
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
+
{{ i "message-square-plus" "w-4 h-4" }}
+
<span>comment</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
+
{{ $disabled := "" }}
+
{{ if $isConflicted }}
+
{{ $disabled = "disabled" }}
+
{{ end }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
+
hx-swap="none"
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
+
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
+
{{ i "git-merge" "w-4 h-4" }}
+
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
{{ end }}
+
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
+
{{ $disabled := "" }}
+
{{ if $isUpToDate }}
+
{{ $disabled = "disabled" }}
{{ end }}
+
<button id="resubmitBtn"
+
{{ if not .Pull.IsPatchBased }}
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
{{ else }}
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
+
{{ end }}
+
hx-disabled-elt="#resubmitBtn"
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
+
{{ if $disabled }}
+
title="Update this branch to resubmit this pull request"
+
{{ else }}
+
title="Resubmit this pull request"
+
{{ end }}
+
>
+
{{ i "rotate-ccw" "w-4 h-4" }}
+
<span>resubmit</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
+
<button
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
hx-swap="none"
class="btn p-2 flex items-center gap-2 group">
+
{{ i "ban" "w-4 h-4" }}
+
<span>close</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
+
{{ end }}
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
+
<button
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
hx-swap="none"
class="btn p-2 flex items-center gap-2 group">
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
+
<span>reopen</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
+
{{ end }}
</div>
</div>
{{ end }}
+
+
+39 -42
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
···
{{ define "repo/pulls/fragments/pullCompareBranches" }}
-
<div id="patch-upload">
-
<label for="targetBranch" class="dark:text-white">
-
select a source branch
-
</label>
-
<div class="flex flex-wrap gap-2 items-center">
-
<select
-
name="sourceBranch"
-
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600">
-
<option disabled selected>source branch</option>
-
{{ $recent := index .Branches 0 }}
-
{{ range .Branches }}
-
{{ $isRecent := eq .Reference.Name $recent.Reference.Name }}
-
{{ $preset := false }}
-
{{ if $.SourceBranch }}
-
{{ $preset = eq .Reference.Name $.SourceBranch }}
-
{{ else }}
-
{{ $preset = $isRecent }}
-
{{ end }}
-
-
-
<option
-
value="{{ .Reference.Name }}"
-
{{ if $preset }}
-
selected
-
{{ end }}
-
class="py-1">
-
{{ .Reference.Name }}
-
{{ if $isRecent }}(new){{ end }}
-
</option>
-
{{ end }}
-
</select>
</div>
-
</div>
-
<div class="flex items-center gap-2">
-
<input type="checkbox" id="isStacked" name="isStacked" value="on" />
-
<label for="isStacked" class="my-0 py-0 normal-case font-normal">
-
Submit as stacked PRs
-
</label>
-
</div>
-
<p class="mt-4">
-
Title and description are optional; if left out, they will be extracted from
-
the first commit.
-
</p>
{{ end }}
···
{{ define "repo/pulls/fragments/pullCompareBranches" }}
+
<div id="patch-upload">
+
<label for="targetBranch" class="dark:text-white">select a source branch</label>
+
<div class="flex flex-wrap gap-2 items-center">
+
<select
+
name="sourceBranch"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
>
+
<option disabled selected>source branch</option>
+
{{ $recent := index .Branches 0 }}
+
{{ range .Branches }}
+
{{ $isRecent := eq .Reference.Name $recent.Reference.Name }}
+
{{ $preset := false }}
+
{{ if $.SourceBranch }}
+
{{ $preset = eq .Reference.Name $.SourceBranch }}
+
{{ else }}
+
{{ $preset = $isRecent }}
+
{{ end }}
+
+
<option
+
value="{{ .Reference.Name }}"
+
{{ if $preset }}
+
selected
+
{{ end }}
+
class="py-1"
+
>
+
{{ .Reference.Name }}
+
{{ if $isRecent }}(new){{ end }}
+
</option>
+
{{ end }}
+
</select>
+
</div>
</div>
+
<div class="flex items-center gap-2">
+
<input type="checkbox" id="isStacked" name="isStacked" value="on">
+
<label for="isStacked" class="my-0 py-0 normal-case font-normal">Submit as stacked PRs</label>
+
</div>
+
<p class="mt-4">
+
Title and description are optional; if left out, they will be extracted
+
from the first commit.
+
</p>
{{ end }}
+22 -20
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
···
{{ define "repo/pulls/fragments/pullCompareForksBranches" }}
-
<div class="flex flex-wrap gap-2 items-center">
-
<select
-
name="sourceBranch"
-
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600">
-
<option disabled selected>source branch</option>
-
{{ $recent := index .SourceBranches 0 }}
-
{{ range .SourceBranches }}
-
{{ $isRecent := eq .Reference.Name $recent.Reference.Name }}
-
<option
-
value="{{ .Reference.Name }}"
-
{{ if $isRecent }}
-
selected
-
{{ end }}
-
class="py-1">
-
{{ .Reference.Name }}
-
{{ if $isRecent }}(new){{ end }}
-
</option>
-
{{ end }}
-
</select>
-
</div>
{{ end }}
···
{{ define "repo/pulls/fragments/pullCompareForksBranches" }}
+
<div class="flex flex-wrap gap-2 items-center">
+
<select
+
name="sourceBranch"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
>
+
<option disabled selected>source branch</option>
+
{{ $recent := index .SourceBranches 0 }}
+
{{ range .SourceBranches }}
+
{{ $isRecent := eq .Reference.Name $recent.Reference.Name }}
+
<option
+
value="{{ .Reference.Name }}"
+
{{ if $isRecent }}
+
selected
+
{{ end }}
+
class="py-1"
+
>
+
{{ .Reference.Name }}
+
{{ if $isRecent }}(new){{ end }}
+
</option>
+
{{ end }}
+
</select>
+
</div>
{{ end }}
+1
appview/pages/templates/repo/pulls/fragments/summarizedPullState.html
···
{{ i $icon $style }}
{{ end }}
···
{{ i $icon $style }}
{{ end }}
+
+146 -136
appview/pages/templates/repo/pulls/new.html
···
{{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
-
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
-
Create new pull request
-
</h2>
-
-
<form
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/new"
-
hx-indicator="#create-pull-spinner"
-
hx-swap="none">
-
<div class="flex flex-col gap-6">
-
<div class="flex gap-2 items-center">
-
<p>First, choose a target branch on {{ .RepoInfo.FullName }}:</p>
-
<div>
-
<select
-
required
-
name="targetBranch"
-
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600">
-
<option disabled selected>target branch</option>
-
-
{{ range .Branches }}
-
{{ $preset := false }}
-
{{ if $.TargetBranch }}
-
{{ $preset = eq .Reference.Name $.TargetBranch }}
-
{{ else }}
-
{{ $preset = .IsDefault }}
-
{{ end }}
-
-
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{ if $preset }}selected{{ end }}>
-
{{ .Reference.Name }}
-
</option>
-
{{ end }}
-
</select>
-
</div>
-
</div>
-
-
<div class="flex flex-col gap-2">
-
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
-
Choose pull strategy
-
</h2>
-
<nav class="flex space-x-4 items-center">
-
<button
-
type="button"
-
class="btn"
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
-
hx-target="#patch-strategy"
-
hx-swap="innerHTML">
-
paste patch
-
</button>
-
-
{{ if .RepoInfo.Roles.IsPushAllowed }}
-
<span class="text-sm text-gray-500 dark:text-gray-400">or</span>
-
<button
-
type="button"
-
class="btn"
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
-
hx-target="#patch-strategy"
-
hx-swap="innerHTML">
-
compare branches
-
</button>
-
{{ end }}
-
-
-
<span class="text-sm text-gray-500 dark:text-gray-400">or</span>
-
<script>
-
function getQueryParams() {
-
return Object.fromEntries(
-
new URLSearchParams(window.location.search),
-
);
-
}
-
</script>
-
<!--
since compare-forks need the server to load forks, we
hx-get this button; unlike simply loading the pullCompareForks template
as we do for the rest of the gang below. the hx-vals thing just populates
the query params so the forks page gets it.
-->
-
<button
-
type="button"
-
class="btn"
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
-
hx-target="#patch-strategy"
-
hx-swap="innerHTML"
-
{{ if eq .Strategy "fork" }}
-
hx-trigger="click, load" hx-vals='js:{...getQueryParams()}'
-
{{ end }}>
-
compare forks
-
</button>
-
</nav>
-
<section id="patch-strategy" class="flex flex-col gap-2">
-
{{ if eq .Strategy "patch" }}
-
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
-
{{ else if eq .Strategy "branch" }}
-
{{ template "repo/pulls/fragments/pullCompareBranches" . }}
-
{{ else }}
-
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
-
{{ end }}
-
</section>
-
-
<div id="patch-error" class="error dark:text-red-300"></div>
-
</div>
-
-
<div>
-
<label for="title" class="dark:text-white">write a title</label>
-
-
<input
-
type="text"
-
name="title"
-
id="title"
-
value="{{ .Title }}"
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
placeholder="One-line summary of your change." />
-
</div>
-
-
<div>
-
<label for="body" class="dark:text-white">add a description</label>
-
-
<textarea
-
name="body"
-
id="body"
-
rows="6"
-
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
placeholder="Describe your change. Markdown is supported.">
-
{{ .Body }}</textarea
-
>
-
</div>
-
-
<div class="flex justify-start items-center gap-2 mt-4">
-
<button type="submit" class="btn-create flex items-center gap-2">
-
{{ i "git-pull-request-create" "w-4 h-4" }}
-
create pull
-
<span id="create-pull-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</div>
-
</div>
-
<div id="pull" class="error dark:text-red-300"></div>
-
</form>
{{ end }}
···
{{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
+
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
+
Create new pull request
+
</h2>
+
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/new"
+
hx-indicator="#create-pull-spinner"
+
hx-swap="none"
+
>
+
<div class="flex flex-col gap-6">
+
<div class="flex gap-2 items-center">
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}:</p>
+
<div>
+
<select
+
required
+
name="targetBranch"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
>
+
<option disabled selected>target branch</option>
+
+
+
{{ range .Branches }}
+
+
{{ $preset := false }}
+
{{ if $.TargetBranch }}
+
{{ $preset = eq .Reference.Name $.TargetBranch }}
+
{{ else }}
+
{{ $preset = .IsDefault }}
+
{{ end }}
+
+
<option value="{{ .Reference.Name }}" class="py-1" {{if $preset}}selected{{end}}>
+
{{ .Reference.Name }}
+
</option>
+
{{ end }}
+
</select>
+
</div>
+
</div>
+
+
<div class="flex flex-col gap-2">
+
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
+
Choose pull strategy
+
</h2>
+
<nav class="flex space-x-4 items-center">
+
<button
+
type="button"
+
class="btn"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
+
hx-target="#patch-strategy"
+
hx-swap="innerHTML"
+
>
+
paste patch
+
</button>
+
+
{{ if .RepoInfo.Roles.IsPushAllowed }}
+
<span class="text-sm text-gray-500 dark:text-gray-400">
+
or
+
</span>
+
<button
+
type="button"
+
class="btn"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
+
hx-target="#patch-strategy"
+
hx-swap="innerHTML"
+
>
+
compare branches
+
</button>
+
{{ end }}
+
+
+
<span class="text-sm text-gray-500 dark:text-gray-400">
+
or
+
</span>
+
<script>
+
function getQueryParams() {
+
return Object.fromEntries(new URLSearchParams(window.location.search));
+
}
+
</script>
+
<!--
since compare-forks need the server to load forks, we
hx-get this button; unlike simply loading the pullCompareForks template
as we do for the rest of the gang below. the hx-vals thing just populates
the query params so the forks page gets it.
-->
+
<button
+
type="button"
+
class="btn"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
+
hx-target="#patch-strategy"
+
hx-swap="innerHTML"
+
{{ if eq .Strategy "fork" }}
+
hx-trigger="click, load"
+
hx-vals='js:{...getQueryParams()}'
+
{{ end }}
+
>
+
compare forks
+
</button>
+
+
+
</nav>
+
<section id="patch-strategy" class="flex flex-col gap-2">
+
{{ if eq .Strategy "patch" }}
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
+
{{ else if eq .Strategy "branch" }}
+
{{ template "repo/pulls/fragments/pullCompareBranches" . }}
+
{{ else }}
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
+
{{ end }}
+
</section>
+
+
<div id="patch-error" class="error dark:text-red-300"></div>
+
</div>
+
+
<div>
+
<label for="title" class="dark:text-white">write a title</label>
+
+
<input
+
type="text"
+
name="title"
+
id="title"
+
value="{{ .Title }}"
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
placeholder="One-line summary of your change."
+
/>
+
</div>
+
+
<div>
+
<label for="body" class="dark:text-white"
+
>add a description</label
+
>
+
+
<textarea
+
name="body"
+
id="body"
+
rows="6"
+
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
placeholder="Describe your change. Markdown is supported."
+
>{{ .Body }}</textarea>
+
</div>
+
+
<div class="flex justify-start items-center gap-2 mt-4">
+
<button type="submit" class="btn-create flex items-center gap-2">
+
{{ i "git-pull-request-create" "w-4 h-4" }}
+
create pull
+
<span id="create-pull-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
</div>
+
<div id="pull" class="error dark:text-red-300"></div>
+
</form>
{{ end }}
+8 -15
appview/pages/templates/repo/settings/fragments/secretListing.html
···
{{ define "repo/settings/fragments/secretListing" }}
{{ $root := index . 0 }}
{{ $secret := index . 1 }}
-
<div
-
id="secret-{{ $secret.Key }}"
-
class="flex items-center justify-between p-2">
-
<div
-
class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
<span class="font-mono">
{{ $secret.Key }}
</span>
-
<div
-
class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
<span>added by</span>
-
<span>
-
{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}
-
</span>
<span class="before:content-['ยท'] before:select-none"></span>
-
<span>
-
{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}
-
</span>
</div>
</div>
<button
···
hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets"
hx-swap="none"
hx-vals='{"key": "{{ $secret.Key }}"}'
-
hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?">
{{ i "trash-2" "w-5 h-5" }}
-
<span class="hidden md:inline">delete</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
</div>
···
{{ define "repo/settings/fragments/secretListing" }}
{{ $root := index . 0 }}
{{ $secret := index . 1 }}
+
<div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2">
+
<div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
<span class="font-mono">
{{ $secret.Key }}
</span>
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
<span>added by</span>
+
<span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span>
<span class="before:content-['ยท'] before:select-none"></span>
+
<span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span>
</div>
</div>
<button
···
hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets"
hx-swap="none"
hx-vals='{"key": "{{ $secret.Key }}"}'
+
hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?"
+
>
{{ i "trash-2" "w-5 h-5" }}
+
<span class="hidden md:inline">delete</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
</div>
+7 -15
appview/pages/templates/repo/settings/fragments/sidebar.html
···
{{ define "repo/settings/fragments/sidebar" }}
{{ $active := .Tab }}
{{ $tabs := .Tabs }}
-
<div
-
class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner">
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
{{ range $tabs }}
-
<a
-
href="/{{ $.RepoInfo.FullName }}/settings?tab={{ .Name }}"
-
class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
-
<div
-
class="flex gap-3 items-center p-2 {{ if eq .Name $active }}
-
{{ $activeTab }}
-
{{ else }}
-
{{ $inactiveTab }}
-
{{ end }}">
-
{{ i .Icon "size-4" }}
-
{{ .Name }}
-
</div>
-
</a>
{{ end }}
</div>
{{ end }}
···
{{ define "repo/settings/fragments/sidebar" }}
{{ $active := .Tab }}
{{ $tabs := .Tabs }}
+
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner">
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
{{ range $tabs }}
+
<a href="/{{ $.RepoInfo.FullName }}/settings?tab={{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
+
<div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
+
{{ i .Icon "size-4" }}
+
{{ .Name }}
+
</div>
+
</a>
{{ end }}
</div>
{{ end }}
+84 -119
appview/pages/templates/timeline.html
···
{{ define "title" }}timeline{{ end }}
{{ define "extrameta" }}
-
<meta property="og:title" content="timeline ยท tangled" />
-
<meta property="og:type" content="object" />
-
<meta property="og:url" content="https://tangled.sh" />
-
<meta property="og:description" content="see what's tangling" />
{{ end }}
{{ define "topbar" }}
···
{{ end }}
{{ define "content" }}
-
{{ with .LoggedInUser }}
-
{{ block "timeline" $ }}{{ end }}
-
{{ else }}
-
{{ block "hero" $ }}{{ end }}
-
{{ block "timeline" $ }}{{ end }}
-
{{ end }}
{{ end }}
{{ define "hero" }}
-
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
-
<div class="font-bold text-4xl">
-
tightly-knit
-
<br />
-
social coding.
-
</div>
-
<p class="text-lg">
-
tangled is new social-enabled git collaboration platform built on
-
<a class="underline" href="https://atproto.com/">atproto</a>
-
.
-
</p>
-
<p class="text-lg">
-
we envision a place where developers have complete ownership of their
-
code, open source communities can freely self-govern and most importantly,
-
coding can be social and fun again.
-
</p>
-
<div class="flex gap-6 items-center">
-
<a href="/signup" class="no-underline hover:no-underline ">
-
<button class="btn-create flex gap-2 px-4 items-center">
-
join now
-
{{ i "arrow-right" "size-4" }}
-
</button>
-
</a>
</div>
-
</div>
{{ end }}
{{ define "timeline" }}
-
<div>
-
<div class="p-6">
-
<p class="text-xl font-bold dark:text-white">Timeline</p>
-
</div>
-
<div class="flex flex-col gap-4">
-
{{ range $i, $e := .Timeline }}
-
<div class="relative">
-
{{ if ne $i 0 }}
-
<div
-
class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
-
{{ end }}
-
{{ with $e }}
-
<div
-
class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
-
{{ if .Repo }}
-
{{ block "repoEvent" (list $ .Repo .Source) }}{{ end }}
-
{{ else if .Star }}
-
{{ block "starEvent" (list $ .Star) }}{{ end }}
-
{{ else if .Follow }}
-
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }}
-
{{ end }}
{{ end }}
</div>
{{ end }}
</div>
-
{{ end }}
</div>
-
</div>
{{ end }}
{{ define "repoEvent" }}
···
{{ $repo := index . 1 }}
{{ $source := index . 2 }}
{{ $userHandle := resolve $repo.Did }}
-
<div
-
class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
-
{{ template "user/fragments/picHandleLink" $repo.Did }}
-
{{ with $source }}
-
{{ $sourceDid := resolve .Did }}
-
forked
-
<a
-
href="/{{ $sourceDid }}/{{ .Name }}"
-
class="no-underline hover:underline">
-
{{ $sourceDid }}/{{ .Name }}
-
</a>
-
to
-
<a
-
href="/{{ $userHandle }}/{{ $repo.Name }}"
-
class="no-underline hover:underline">
-
{{ $repo.Name }}
-
</a>
-
{{ else }}
-
created
-
<a
-
href="/{{ $userHandle }}/{{ $repo.Name }}"
-
class="no-underline hover:underline">
-
{{ $repo.Name }}
-
</a>
-
{{ end }}
-
<span class="text-gray-700 dark:text-gray-400 text-xs">
-
{{ template "repo/fragments/time" $repo.Created }}
-
</span>
-
</div>
{{ with $repo }}
{{ template "user/fragments/repoCard" (list $root . true) }}
{{ end }}
···
{{ with $star }}
{{ $starrerHandle := resolve .StarredByDid }}
{{ $repoOwnerHandle := resolve .Repo.Did }}
-
<div
-
class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
-
{{ template "user/fragments/picHandleLink" $starrerHandle }}
-
starred
-
<a
-
href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}"
-
class="no-underline hover:underline">
-
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
-
</a>
-
<span class="text-gray-700 dark:text-gray-400 text-xs">
-
{{ template "repo/fragments/time" .Created }}
-
</span>
</div>
{{ with .Repo }}
{{ template "user/fragments/repoCard" (list $root . true) }}
···
{{ end }}
{{ end }}
{{ define "followEvent" }}
{{ $root := index . 0 }}
{{ $follow := index . 1 }}
···
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
-
<div
-
class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
-
{{ template "user/fragments/picHandleLink" $userHandle }}
-
followed
-
{{ template "user/fragments/picHandleLink" $subjectHandle }}
-
<span class="text-gray-700 dark:text-gray-400 text-xs">
-
{{ template "repo/fragments/time" $follow.FollowedAt }}
-
</span>
</div>
-
<div
-
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
<div class="flex-shrink-0 max-h-full w-24 h-24">
-
<img
-
class="object-cover rounded-full p-2"
-
src="{{ fullAvatar $subjectHandle }}" />
</div>
<div class="flex-1 min-h-0 justify-around flex flex-col">
<a href="/{{ $subjectHandle }}">
-
<span
-
class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
{{ $subjectHandle | truncateAt30 }}
-
</span>
</a>
{{ with $profile }}
{{ with .Description }}
-
<p class="text-sm pb-2 md:pb-2">{{ . }}</p>
{{ end }}
{{ end }}
{{ with $stat }}
-
<div
-
class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
<span id="followers">{{ .Followers }} followers</span>
<span class="select-none after:content-['ยท']"></span>
-
<span id="following">{{ .Following }} following</span>
</div>
{{ end }}
</div>
···
{{ define "title" }}timeline{{ end }}
{{ define "extrameta" }}
+
<meta property="og:title" content="timeline ยท tangled" />
+
<meta property="og:type" content="object" />
+
<meta property="og:url" content="https://tangled.sh" />
+
<meta property="og:description" content="see what's tangling" />
{{ end }}
{{ define "topbar" }}
···
{{ end }}
{{ define "content" }}
+
{{ with .LoggedInUser }}
+
{{ block "timeline" $ }}{{ end }}
+
{{ else }}
+
{{ block "hero" $ }}{{ end }}
+
{{ block "timeline" $ }}{{ end }}
+
{{ end }}
{{ end }}
{{ define "hero" }}
+
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
+
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
+
<p class="text-lg">
+
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
+
</p>
+
<p class="text-lg">
+
we envision a place where developers have complete ownership of their
+
code, open source communities can freely self-govern and most
+
importantly, coding can be social and fun again.
+
</p>
+
<div class="flex gap-6 items-center">
+
<a href="/signup" class="no-underline hover:no-underline ">
+
<button class="btn-create flex gap-2 px-4 items-center">
+
join now {{ i "arrow-right" "size-4" }}
+
</button>
+
</a>
+
</div>
</div>
{{ end }}
{{ define "timeline" }}
+
<div>
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
+
</div>
+
<div class="flex flex-col gap-4">
+
{{ range $i, $e := .Timeline }}
+
<div class="relative">
+
{{ if ne $i 0 }}
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
+
{{ end }}
+
{{ with $e }}
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ if .Repo }}
+
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
+
{{ else if .Star }}
+
{{ block "starEvent" (list $ .Star) }} {{ end }}
+
{{ else if .Follow }}
+
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
+
{{ end }}
+
</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
{{ end }}
{{ define "repoEvent" }}
···
{{ $repo := index . 1 }}
{{ $source := index . 2 }}
{{ $userHandle := resolve $repo.Did }}
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
+
{{ template "user/fragments/picHandleLink" $repo.Did }}
+
{{ with $source }}
+
{{ $sourceDid := resolve .Did }}
+
forked
+
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
+
{{ $sourceDid }}/{{ .Name }}
+
</a>
+
to
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
+
{{ else }}
+
created
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
+
{{ $repo.Name }}
+
</a>
+
{{ end }}
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
+
</div>
{{ with $repo }}
{{ template "user/fragments/repoCard" (list $root . true) }}
{{ end }}
···
{{ with $star }}
{{ $starrerHandle := resolve .StarredByDid }}
{{ $repoOwnerHandle := resolve .Repo.Did }}
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
+
{{ template "user/fragments/picHandleLink" $starrerHandle }}
+
starred
+
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
+
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
+
</a>
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
</div>
{{ with .Repo }}
{{ template "user/fragments/repoCard" (list $root . true) }}
···
{{ end }}
{{ end }}
+
{{ define "followEvent" }}
{{ $root := index . 0 }}
{{ $follow := index . 1 }}
···
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
+
{{ template "user/fragments/picHandleLink" $userHandle }}
+
followed
+
{{ template "user/fragments/picHandleLink" $subjectHandle }}
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
</div>
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
<div class="flex-shrink-0 max-h-full w-24 h-24">
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
</div>
<div class="flex-1 min-h-0 justify-around flex flex-col">
<a href="/{{ $subjectHandle }}">
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
</a>
{{ with $profile }}
{{ with .Description }}
+
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
{{ end }}
{{ end }}
{{ with $stat }}
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
<span id="followers">{{ .Followers }} followers</span>
<span class="select-none after:content-['ยท']"></span>
+
<span id="following">{{ .Following }} following</span>
</div>
{{ end }}
</div>
+29 -48
appview/pages/templates/user/fragments/editPins.html
···
{{ define "user/fragments/editPins" }}
{{ $profile := .Profile }}
-
<form
-
hx-post="/profile/pins"
-
hx-disabled-elt="#save-btn,#cancel-btn"
-
hx-swap="none"
-
hx-indicator="#spinner">
-
<div class="flex items-center justify-between mb-2">
-
<p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p>
-
<div class="flex items-center gap-2">
-
<button
-
id="save-btn"
-
type="submit"
-
class="btn px-2 flex items-center gap-2 no-underline text-sm">
-
{{ i "check" "w-3 h-3" }} save
-
<span id="spinner" class="group">
-
{{ i "loader-circle" "w-3 h-3 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
<a
-
href="/{{ .LoggedInUser.Did }}"
-
class="w-full no-underline hover:no-underline">
-
<button
-
id="cancel-btn"
-
type="button"
-
class="btn px-2 w-full flex items-center gap-2 no-underline text-sm">
-
{{ i "x" "w-3 h-3" }} cancel
</button>
-
</a>
</div>
-
</div>
-
<div
-
id="repos"
-
class="grid grid-cols-1 gap-1 mb-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
-
{{ range $idx, $r := .AllRepos }}
-
<div
-
class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700">
-
<input
-
type="checkbox"
-
id="repo-{{ $idx }}"
-
name="pinnedRepo{{ $idx }}"
-
value="{{ .RepoAt }}"
-
{{ if .IsPinned }}checked{{ end }} />
-
<label
-
for="repo-{{ $idx }}"
-
class="my-0 py-0 normal-case font-normal w-full">
<div class="flex justify-between items-center w-full">
-
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">
-
{{ resolve .Did }}/{{ .Name }}
-
</span>
<div class="flex gap-1 items-center">
{{ i "star" "size-4 fill-current" }}
<span>{{ .RepoStats.StarCount }}</span>
···
</div>
</label>
</div>
-
{{ end }}
-
</div>
-
</form>
{{ end }}
···
{{ define "user/fragments/editPins" }}
{{ $profile := .Profile }}
+
<form
+
hx-post="/profile/pins"
+
hx-disabled-elt="#save-btn,#cancel-btn"
+
hx-swap="none"
+
hx-indicator="#spinner">
+
<div class="flex items-center justify-between mb-2">
+
<p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p>
+
<div class="flex items-center gap-2">
+
<button id="save-btn" type="submit" class="btn px-2 flex items-center gap-2 no-underline text-sm">
+
{{ i "check" "w-3 h-3" }} save
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "w-3 h-3 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
</button>
+
<a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline">
+
<button id="cancel-btn" type="button" class="btn px-2 w-full flex items-center gap-2 no-underline text-sm">
+
{{ i "x" "w-3 h-3" }} cancel
+
</button>
+
</a>
+
</div>
</div>
+
<div id="repos" class="grid grid-cols-1 gap-1 mb-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
+
{{ range $idx, $r := .AllRepos }}
+
<div class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700">
+
<input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}>
+
<label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full">
<div class="flex justify-between items-center w-full">
+
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span>
<div class="flex gap-1 items-center">
{{ i "star" "size-4 fill-current" }}
<span>{{ .RepoStats.StarCount }}</span>
···
</div>
</label>
</div>
+
{{ end }}
+
</div>
+
+
</form>
{{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
···
-
{{ define "repo/fragments/cloneInstructions" }}
-
{{ $knot := .RepoInfo.Knot }}
-
{{ if eq $knot "knot1.tangled.sh" }}
-
{{ $knot = "tangled.sh" }}
-
{{ end }}
-
<section
-
class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4"
-
>
-
<div class="flex flex-col gap-2">
-
<strong>push</strong>
-
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
-
<code class="dark:text-gray-100"
-
>git remote add origin
-
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
-
>
-
</div>
-
</div>
-
-
<div class="flex flex-col gap-2">
-
<strong>clone</strong>
-
<div class="md:pl-4 flex flex-col gap-2">
-
<div class="flex items-center gap-3">
-
<span
-
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
-
>HTTP</span
-
>
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
-
<code class="dark:text-gray-100"
-
>git clone
-
https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code
-
>
-
</div>
-
</div>
-
-
<div class="flex items-center gap-3">
-
<span
-
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
-
>SSH</span
-
>
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
-
<code class="dark:text-gray-100"
-
>git clone
-
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
-
>
-
</div>
-
</div>
-
</div>
-
</div>
-
-
<p class="py-2 text-gray-500 dark:text-gray-400">
-
Note that for self-hosted knots, clone URLs may be different based
-
on your setup.
-
</p>
-
</section>
-
{{ end }}
···
-4
appview/pages/templates/repo/empty.html
···
{{ end }}
</main>
{{ end }}
-
-
{{ define "repoAfter" }}
-
{{ template "repo/fragments/cloneInstructions" . }}
-
{{ end }}
···
{{ end }}
</main>
{{ end }}
+1 -1
spindle/engine/ansi_stripper.go spindle/engines/nixery/ansi_stripper.go
···
-
package engine
import (
"io"
···
+
package nixery
import (
"io"
+1 -1
spindle/engine/envs.go spindle/engines/nixery/envs.go
···
-
package engine
import (
"fmt"
···
+
package nixery
import (
"fmt"
+1 -1
spindle/engine/envs_test.go spindle/engines/nixery/envs_test.go
···
-
package engine
import (
"testing"
···
+
package nixery
import (
"testing"
+7
spindle/engines/nixery/errors.go
···
···
+
package nixery
+
+
import "errors"
+
+
var (
+
ErrOOMKilled = errors.New("oom killed")
+
)
+8 -10
spindle/engine/logger.go spindle/models/logger.go
···
-
package engine
import (
"encoding/json"
···
"os"
"path/filepath"
"strings"
-
-
"tangled.sh/tangled.sh/core/spindle/models"
)
type WorkflowLogger struct {
···
encoder *json.Encoder
}
-
func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) {
path := LogFilePath(baseDir, wid)
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
}, nil
}
-
func LogFilePath(baseDir string, workflowID models.WorkflowId) string {
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
return logFilePath
}
···
}
}
-
func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer {
return &controlWriter{
logger: l,
idx: idx,
···
func (w *dataWriter) Write(p []byte) (int, error) {
line := strings.TrimRight(string(p), "\r\n")
-
entry := models.NewDataLogLine(line, w.stream)
if err := w.logger.encoder.Encode(entry); err != nil {
return 0, err
}
···
type controlWriter struct {
logger *WorkflowLogger
idx int
-
step models.Step
}
func (w *controlWriter) Write(_ []byte) (int, error) {
-
entry := models.NewControlLogLine(w.idx, w.step)
if err := w.logger.encoder.Encode(entry); err != nil {
return 0, err
}
-
return len(w.step.Name), nil
}
···
+
package models
import (
"encoding/json"
···
"os"
"path/filepath"
"strings"
)
type WorkflowLogger struct {
···
encoder *json.Encoder
}
+
func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) {
path := LogFilePath(baseDir, wid)
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
}, nil
}
+
func LogFilePath(baseDir string, workflowID WorkflowId) string {
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
return logFilePath
}
···
}
}
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
return &controlWriter{
logger: l,
idx: idx,
···
func (w *dataWriter) Write(p []byte) (int, error) {
line := strings.TrimRight(string(p), "\r\n")
+
entry := NewDataLogLine(line, w.stream)
if err := w.logger.encoder.Encode(entry); err != nil {
return 0, err
}
···
type controlWriter struct {
logger *WorkflowLogger
idx int
+
step Step
}
func (w *controlWriter) Write(_ []byte) (int, error) {
+
entry := NewControlLogLine(w.idx, w.step)
if err := w.logger.encoder.Encode(entry); err != nil {
return 0, err
}
+
return len(w.step.Name()), nil
}
+8 -103
spindle/models/pipeline.go
···
package models
-
import (
-
"path"
-
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/spindle/config"
-
)
-
type Pipeline struct {
RepoOwner string
RepoName string
-
Workflows []Workflow
}
-
type Step struct {
-
Command string
-
Name string
-
Environment map[string]string
-
Kind StepKind
}
type StepKind int
···
)
type Workflow struct {
-
Steps []Step
-
Environment map[string]string
-
Name string
-
Image string
-
}
-
-
// setupSteps get added to start of Steps
-
type setupSteps []Step
-
-
// addStep adds a step to the beginning of the workflow's steps.
-
func (ss *setupSteps) addStep(step Step) {
-
*ss = append(*ss, step)
-
}
-
-
// ToPipeline converts a tangled.Pipeline into a model.Pipeline.
-
// In the process, dependencies are resolved: nixpkgs deps
-
// are constructed atop nixery and set as the Workflow.Image,
-
// and ones from custom registries
-
func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline {
-
workflows := []Workflow{}
-
-
for _, twf := range pl.Workflows {
-
swf := &Workflow{}
-
for _, tstep := range twf.Steps {
-
sstep := Step{}
-
sstep.Environment = stepEnvToMap(tstep.Environment)
-
sstep.Command = tstep.Command
-
sstep.Name = tstep.Name
-
sstep.Kind = StepKindUser
-
swf.Steps = append(swf.Steps, sstep)
-
}
-
swf.Name = twf.Name
-
swf.Environment = workflowEnvToMap(twf.Environment)
-
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
-
-
setup := &setupSteps{}
-
-
setup.addStep(nixConfStep())
-
setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev))
-
// this step could be empty
-
if s := dependencyStep(*twf); s != nil {
-
setup.addStep(*s)
-
}
-
-
// append setup steps in order to the start of workflow steps
-
swf.Steps = append(*setup, swf.Steps...)
-
-
workflows = append(workflows, *swf)
-
}
-
repoOwner := pl.TriggerMetadata.Repo.Did
-
repoName := pl.TriggerMetadata.Repo.Repo
-
return &Pipeline{
-
RepoOwner: repoOwner,
-
RepoName: repoName,
-
Workflows: workflows,
-
}
-
}
-
-
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
-
envMap := map[string]string{}
-
for _, env := range envs {
-
if env != nil {
-
envMap[env.Key] = env.Value
-
}
-
}
-
return envMap
-
}
-
-
func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
-
envMap := map[string]string{}
-
for _, env := range envs {
-
if env != nil {
-
envMap[env.Key] = env.Value
-
}
-
}
-
return envMap
-
}
-
-
func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string {
-
var dependencies string
-
for _, d := range deps {
-
if d.Registry == "nixpkgs" {
-
dependencies = path.Join(d.Packages...)
-
}
-
}
-
-
// load defaults from somewhere else
-
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
-
-
return path.Join(nixery, dependencies)
}
···
package models
type Pipeline struct {
RepoOwner string
RepoName string
+
Workflows map[Engine][]Workflow
}
+
type Step interface {
+
Name() string
+
Command() string
+
Kind() StepKind
}
type StepKind int
···
)
type Workflow struct {
+
Steps []Step
+
Name string
+
Data any
}
+1 -86
workflow/def_test.go
···
yamlData := `
when:
- event: ["push", "pull_request"]
-
branch: ["main", "develop"]
-
-
dependencies:
-
nixpkgs:
-
- go
-
- git
-
- curl
-
-
steps:
-
- name: "Test"
-
command: |
-
go test ./...`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err, "YAML should unmarshal without error")
···
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event)
-
assert.Len(t, wf.Steps, 1)
-
assert.Equal(t, "Test", wf.Steps[0].Name)
-
assert.Equal(t, "go test ./...", wf.Steps[0].Command)
-
-
pkgs, ok := wf.Dependencies["nixpkgs"]
-
assert.True(t, ok, "`nixpkgs` should be present in dependencies")
-
assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs)
-
assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
}
-
func TestUnmarshalCustomRegistry(t *testing.T) {
-
yamlData := `
-
when:
-
- event: push
-
branch: main
-
-
dependencies:
-
git+https://tangled.sh/@oppi.li/tbsp:
-
- tbsp
-
git+https://git.peppe.rs/languages/statix:
-
- statix
-
-
steps:
-
- name: "Check"
-
command: |
-
statix check`
-
-
wf, err := FromFile("test.yml", []byte(yamlData))
-
assert.NoError(t, err, "YAML should unmarshal without error")
-
-
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
-
assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch)
-
-
assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"])
-
assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"])
-
}
-
func TestUnmarshalCloneFalse(t *testing.T) {
yamlData := `
when:
···
clone:
skip: true
-
-
dependencies:
-
nixpkgs:
-
- python3
-
-
steps:
-
- name: Notify
-
command: |
-
python3 ./notify.py
`
wf, err := FromFile("test.yml", []byte(yamlData))
···
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
}
-
-
func TestUnmarshalEnv(t *testing.T) {
-
yamlData := `
-
when:
-
- event: ["pull_request_close"]
-
-
clone:
-
skip: false
-
-
environment:
-
HOME: /home/foo bar/baz
-
CGO_ENABLED: 1
-
-
steps:
-
- name: Something
-
command: echo "hello"
-
environment:
-
FOO: bar
-
BAZ: qux
-
`
-
-
wf, err := FromFile("test.yml", []byte(yamlData))
-
assert.NoError(t, err)
-
-
assert.Len(t, wf.Environment, 2)
-
assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"])
-
assert.Equal(t, "1", wf.Environment["CGO_ENABLED"])
-
assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"])
-
assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"])
-
}
···
yamlData := `
when:
- event: ["push", "pull_request"]
+
branch: ["main", "develop"]`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err, "YAML should unmarshal without error")
···
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event)
assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
}
func TestUnmarshalCloneFalse(t *testing.T) {
yamlData := `
when:
···
clone:
skip: true
`
wf, err := FromFile("test.yml", []byte(yamlData))
···
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
}
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
···
{{ define "repo/fragments/repoDescription" }}
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
{{ if .RepoInfo.Description }}
-
{{ .RepoInfo.Description }}
{{ else }}
<span class="italic">this repo has no description</span>
{{ end }}
···
{{ define "repo/fragments/repoDescription" }}
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
{{ if .RepoInfo.Description }}
+
{{ .RepoInfo.Description | description }}
{{ else }}
<span class="italic">this repo has no description</span>
{{ end }}
+4 -8
knotserver/git/fork.go
···
)
func Fork(repoPath, source string) error {
-
_, err := git.PlainClone(repoPath, true, &git.CloneOptions{
-
URL: source,
-
SingleBranch: false,
-
})
-
-
if err != nil {
return fmt.Errorf("failed to bare clone repository: %w", err)
}
-
err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run()
-
if err != nil {
return fmt.Errorf("failed to configure hidden refs: %w", err)
}
···
)
func Fork(repoPath, source string) error {
+
cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath)
+
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to bare clone repository: %w", err)
}
+
configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden")
+
if err := configureCmd.Run(); err != nil {
return fmt.Errorf("failed to configure hidden refs: %w", err)
}
+2
.tangled/workflows/build.yml
···
- event: ["push", "pull_request"]
branch: ["master"]
dependencies:
nixpkgs:
- go
···
- event: ["push", "pull_request"]
branch: ["master"]
+
engine: nixery
+
dependencies:
nixpkgs:
- go
+2
.tangled/workflows/fmt.yml
···
- event: ["push", "pull_request"]
branch: ["master"]
steps:
- name: "Check formatting"
command: |
···
- event: ["push", "pull_request"]
branch: ["master"]
+
engine: nixery
+
steps:
- name: "Check formatting"
command: |
+1 -1
appview/pages/templates/strings/fragments/form.html
···
type="text"
id="filename"
name="filename"
-
placeholder="Filename with extension"
required
value="{{ .String.Filename }}"
class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
···
type="text"
id="filename"
name="filename"
+
placeholder="Filename"
required
value="{{ .String.Filename }}"
class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
+62
appview/pages/templates/user/settings/fragments/emailListing.html
···
···
+
{{ define "user/settings/fragments/emailListing" }}
+
{{ $root := index . 0 }}
+
{{ $email := index . 1 }}
+
<div id="email-{{$email.Address}}" class="flex items-center justify-between p-2">
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
+
<div class="flex items-center gap-2">
+
{{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }}
+
<span class="font-bold">
+
{{ $email.Address }}
+
</span>
+
<div class="inline-flex items-center gap-1">
+
{{ if $email.Verified }}
+
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
+
{{ else }}
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
+
{{ end }}
+
{{ if $email.Primary }}
+
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
+
{{ end }}
+
</div>
+
</div>
+
<div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span>
+
</div>
+
</div>
+
<div class="flex gap-2 items-center">
+
{{ if not $email.Verified }}
+
<button
+
class="btn flex gap-2 text-sm px-2 py-1"
+
hx-post="/settings/emails/verify/resend"
+
hx-swap="none"
+
hx-vals='{"email": "{{ $email.Address }}"}'>
+
{{ i "rotate-cw" "w-4 h-4" }}
+
<span class="hidden md:inline">resend</span>
+
</button>
+
{{ end }}
+
{{ if and (not $email.Primary) $email.Verified }}
+
<button
+
class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+
hx-post="/settings/emails/primary"
+
hx-swap="none"
+
hx-vals='{"email": "{{ $email.Address }}"}'>
+
set as primary
+
</button>
+
{{ end }}
+
{{ if not $email.Primary }}
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Delete email"
+
hx-delete="/settings/emails"
+
hx-swap="none"
+
hx-vals='{"email": "{{ $email.Address }}"}'
+
hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?"
+
>
+
{{ i "trash-2" "w-5 h-5" }}
+
<span class="hidden md:inline">delete</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
···
···
+
{{ define "user/settings/fragments/keyListing" }}
+
{{ $root := index . 0 }}
+
{{ $key := index . 1 }}
+
<div id="key-{{$key.Name}}" class="flex items-center justify-between p-2">
+
<div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]">
+
<div class="flex items-center gap-2">
+
<span>{{ i "key" "w-4" "h-4" }}</span>
+
<span class="font-bold">
+
{{ $key.Name }}
+
</span>
+
</div>
+
<span class="font-mono text-sm text-gray-500 dark:text-gray-400">
+
{{ sshFingerprint $key.Key }}
+
</span>
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>added {{ template "repo/fragments/time" $key.Created }}</span>
+
</div>
+
</div>
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Delete key"
+
hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}"
+
hx-swap="none"
+
hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?"
+
>
+
{{ i "trash-2" "w-5 h-5" }}
+
<span class="hidden md:inline">delete</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</div>
+
{{ end }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
···
···
+
{{ define "user/settings/fragments/sidebar" }}
+
{{ $active := .Tab }}
+
{{ $tabs := .Tabs }}
+
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner">
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
+
{{ range $tabs }}
+
<a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
+
<div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
+
{{ i .Icon "size-4" }}
+
{{ .Name }}
+
</div>
+
</a>
+
{{ end }}
+
</div>
+
{{ end }}
+34
api/tangled/repocreate.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.create
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoCreateNSID = "sh.tangled.repo.create"
+
)
+
+
// RepoCreate_Input is the input argument to a sh.tangled.repo.create call.
+
type RepoCreate_Input struct {
+
// defaultBranch: Default branch to push to
+
DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"`
+
// rkey: Rkey of the repository record
+
Rkey string `json:"rkey" cborgen:"rkey"`
+
// source: A source URL to clone from, populate this when forking or importing a repository.
+
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
+
}
+
+
// RepoCreate calls the XRPC method "sh.tangled.repo.create".
+
func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+45
api/tangled/repoforkStatus.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.forkStatus
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoForkStatusNSID = "sh.tangled.repo.forkStatus"
+
)
+
+
// RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call.
+
type RepoForkStatus_Input struct {
+
// branch: Branch to check status for
+
Branch string `json:"branch" cborgen:"branch"`
+
// did: DID of the fork owner
+
Did string `json:"did" cborgen:"did"`
+
// hiddenRef: Hidden ref to use for comparison
+
HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"`
+
// name: Name of the forked repository
+
Name string `json:"name" cborgen:"name"`
+
// source: Source repository URL
+
Source string `json:"source" cborgen:"source"`
+
}
+
+
// RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call.
+
type RepoForkStatus_Output struct {
+
// status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch
+
Status int64 `json:"status" cborgen:"status"`
+
}
+
+
// RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus".
+
func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) {
+
var out RepoForkStatus_Output
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+36
api/tangled/repoforkSync.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.forkSync
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoForkSyncNSID = "sh.tangled.repo.forkSync"
+
)
+
+
// RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call.
+
type RepoForkSync_Input struct {
+
// branch: Branch to sync
+
Branch string `json:"branch" cborgen:"branch"`
+
// did: DID of the fork owner
+
Did string `json:"did" cborgen:"did"`
+
// name: Name of the forked repository
+
Name string `json:"name" cborgen:"name"`
+
// source: AT-URI of the source repository
+
Source string `json:"source" cborgen:"source"`
+
}
+
+
// RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync".
+
func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+45
api/tangled/repohiddenRef.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.hiddenRef
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef"
+
)
+
+
// RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call.
+
type RepoHiddenRef_Input struct {
+
// forkRef: Fork reference name
+
ForkRef string `json:"forkRef" cborgen:"forkRef"`
+
// remoteRef: Remote reference name
+
RemoteRef string `json:"remoteRef" cborgen:"remoteRef"`
+
// repo: AT-URI of the repository
+
Repo string `json:"repo" cborgen:"repo"`
+
}
+
+
// RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call.
+
type RepoHiddenRef_Output struct {
+
// error: Error message if creation failed
+
Error *string `json:"error,omitempty" cborgen:"error,omitempty"`
+
// ref: The created hidden ref name
+
Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"`
+
// success: Whether the hidden ref was created successfully
+
Success bool `json:"success" cborgen:"success"`
+
}
+
+
// RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef".
+
func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) {
+
var out RepoHiddenRef_Output
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+44
api/tangled/repomerge.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.merge
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoMergeNSID = "sh.tangled.repo.merge"
+
)
+
+
// RepoMerge_Input is the input argument to a sh.tangled.repo.merge call.
+
type RepoMerge_Input struct {
+
// authorEmail: Author email for the merge commit
+
AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"`
+
// authorName: Author name for the merge commit
+
AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"`
+
// branch: Target branch to merge into
+
Branch string `json:"branch" cborgen:"branch"`
+
// commitBody: Additional commit message body
+
CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"`
+
// commitMessage: Merge commit message
+
CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"`
+
// did: DID of the repository owner
+
Did string `json:"did" cborgen:"did"`
+
// name: Name of the repository
+
Name string `json:"name" cborgen:"name"`
+
// patch: Patch content to merge
+
Patch string `json:"patch" cborgen:"patch"`
+
}
+
+
// RepoMerge calls the XRPC method "sh.tangled.repo.merge".
+
func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+57
api/tangled/repomergeCheck.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.mergeCheck
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck"
+
)
+
+
// RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema.
+
type RepoMergeCheck_ConflictInfo struct {
+
// filename: Name of the conflicted file
+
Filename string `json:"filename" cborgen:"filename"`
+
// reason: Reason for the conflict
+
Reason string `json:"reason" cborgen:"reason"`
+
}
+
+
// RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call.
+
type RepoMergeCheck_Input struct {
+
// branch: Target branch to merge into
+
Branch string `json:"branch" cborgen:"branch"`
+
// did: DID of the repository owner
+
Did string `json:"did" cborgen:"did"`
+
// name: Name of the repository
+
Name string `json:"name" cborgen:"name"`
+
// patch: Patch or pull request to check for merge conflicts
+
Patch string `json:"patch" cborgen:"patch"`
+
}
+
+
// RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call.
+
type RepoMergeCheck_Output struct {
+
// conflicts: List of files with merge conflicts
+
Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"`
+
// error: Error message if check failed
+
Error *string `json:"error,omitempty" cborgen:"error,omitempty"`
+
// is_conflicted: Whether the merge has conflicts
+
Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"`
+
// message: Additional message about the merge check
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
+
}
+
+
// RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck".
+
func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) {
+
var out RepoMergeCheck_Output
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+24
lexicons/knot/knot.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.knot",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "any",
+
"record": {
+
"type": "object",
+
"required": [
+
"createdAt"
+
],
+
"properties": {
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+33
lexicons/repo/create.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.create",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Create a new repository",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"rkey"
+
],
+
"properties": {
+
"rkey": {
+
"type": "string",
+
"description": "Rkey of the repository record"
+
},
+
"defaultBranch": {
+
"type": "string",
+
"description": "Default branch to push to"
+
},
+
"source": {
+
"type": "string",
+
"description": "A source URL to clone from, populate this when forking or importing a repository."
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+53
lexicons/repo/forkStatus.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.forkStatus",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Check fork status relative to upstream source",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "name", "source", "branch", "hiddenRef"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the fork owner"
+
},
+
"name": {
+
"type": "string",
+
"description": "Name of the forked repository"
+
},
+
"source": {
+
"type": "string",
+
"description": "Source repository URL"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Branch to check status for"
+
},
+
"hiddenRef": {
+
"type": "string",
+
"description": "Hidden ref to use for comparison"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["status"],
+
"properties": {
+
"status": {
+
"type": "integer",
+
"description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+42
lexicons/repo/forkSync.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.forkSync",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Sync a forked repository with its upstream source",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"did",
+
"source",
+
"name",
+
"branch"
+
],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the fork owner"
+
},
+
"source": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the source repository"
+
},
+
"name": {
+
"type": "string",
+
"description": "Name of the forked repository"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Branch to sync"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+59
lexicons/repo/hiddenRef.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.hiddenRef",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Create a hidden ref in a repository",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"repo",
+
"forkRef",
+
"remoteRef"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the repository"
+
},
+
"forkRef": {
+
"type": "string",
+
"description": "Fork reference name"
+
},
+
"remoteRef": {
+
"type": "string",
+
"description": "Remote reference name"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"success"
+
],
+
"properties": {
+
"success": {
+
"type": "boolean",
+
"description": "Whether the hidden ref was created successfully"
+
},
+
"ref": {
+
"type": "string",
+
"description": "The created hidden ref name"
+
},
+
"error": {
+
"type": "string",
+
"description": "Error message if creation failed"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+52
lexicons/repo/merge.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.merge",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Merge a patch into a repository branch",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "name", "patch", "branch"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the repository owner"
+
},
+
"name": {
+
"type": "string",
+
"description": "Name of the repository"
+
},
+
"patch": {
+
"type": "string",
+
"description": "Patch content to merge"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Target branch to merge into"
+
},
+
"authorName": {
+
"type": "string",
+
"description": "Author name for the merge commit"
+
},
+
"authorEmail": {
+
"type": "string",
+
"description": "Author email for the merge commit"
+
},
+
"commitBody": {
+
"type": "string",
+
"description": "Additional commit message body"
+
},
+
"commitMessage": {
+
"type": "string",
+
"description": "Merge commit message"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+79
lexicons/repo/mergeCheck.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.mergeCheck",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Check if a merge is possible between two branches",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "name", "patch", "branch"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the repository owner"
+
},
+
"name": {
+
"type": "string",
+
"description": "Name of the repository"
+
},
+
"patch": {
+
"type": "string",
+
"description": "Patch or pull request to check for merge conflicts"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Target branch to merge into"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["is_conflicted"],
+
"properties": {
+
"is_conflicted": {
+
"type": "boolean",
+
"description": "Whether the merge has conflicts"
+
},
+
"conflicts": {
+
"type": "array",
+
"description": "List of files with merge conflicts",
+
"items": {
+
"type": "ref",
+
"ref": "#conflictInfo"
+
}
+
},
+
"message": {
+
"type": "string",
+
"description": "Additional message about the merge check"
+
},
+
"error": {
+
"type": "string",
+
"description": "Error message if check failed"
+
}
+
}
+
}
+
}
+
},
+
"conflictInfo": {
+
"type": "object",
+
"required": ["filename", "reason"],
+
"properties": {
+
"filename": {
+
"type": "string",
+
"description": "Name of the conflicted file"
+
},
+
"reason": {
+
"type": "string",
+
"description": "Reason for the conflict"
+
}
+
}
+
}
+
}
+
}
+22
api/tangled/tangledknot.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.knot
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
KnotNSID = "sh.tangled.knot"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.knot", &Knot{})
+
} //
+
// RECORDTYPE: Knot
+
type Knot struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
}
+93 -28
appview/pages/templates/knots/dashboard.html
···
-
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
{{ define "content" }}
-
<div class="px-6 py-4">
-
<div class="flex justify-between items-center">
-
<div id="left-side" class="flex gap-2 items-center">
-
<h1 class="text-xl font-bold dark:text-white">
-
{{ .Registration.Domain }}
-
</h1>
-
<span class="text-gray-500 text-base">
-
{{ template "repo/fragments/shortTimeAgo" .Registration.Created }}
-
</span>
-
</div>
-
<div id="right-side" class="flex gap-2">
-
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
-
{{ if .Registration.Registered }}
-
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
{{ template "knots/fragments/addMemberModal" .Registration }}
-
{{ else }}
-
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span>
{{ end }}
-
</div>
</div>
-
<div id="operation-error" class="dark:text-red-400"></div>
</div>
-
{{ if .Members }}
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
-
<div class="flex flex-col gap-2">
-
{{ block "knotMember" . }} {{ end }}
-
</div>
-
</section>
-
{{ end }}
{{ end }}
-
{{ define "knotMember" }}
{{ range .Members }}
<div>
<div class="flex justify-between items-center">
···
{{ template "user/fragments/picHandleLink" . }}
<span class="ml-2 font-mono text-gray-500">{{.}}</span>
</div>
</div>
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
{{ $repos := index $.Repos . }}
···
</div>
{{ else }}
<div class="text-gray-500 dark:text-gray-400">
-
No repositories created yet.
</div>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
···
+
{{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }}
{{ define "content" }}
+
<div class="px-6 py-4">
+
<div class="flex justify-between items-center">
+
<h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1>
+
<div id="right-side" class="flex gap-2">
+
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
+
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }}
+
{{ if .Registration.IsRegistered }}
+
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
+
{{ if $isOwner }}
{{ template "knots/fragments/addMemberModal" .Registration }}
{{ end }}
+
{{ else if .Registration.IsReadOnly }}
+
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}">
+
{{ i "shield-alert" "w-4 h-4" }} read-only
+
</span>
+
{{ if $isOwner }}
+
{{ block "retryButton" .Registration }} {{ end }}
+
{{ end }}
+
{{ else }}
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
+
{{ if $isOwner }}
+
{{ block "retryButton" .Registration }} {{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ if $isOwner }}
+
{{ block "deleteButton" .Registration }} {{ end }}
+
{{ end }}
</div>
</div>
+
<div id="operation-error" class="dark:text-red-400"></div>
+
</div>
+
{{ if .Members }}
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<div class="flex flex-col gap-2">
+
{{ block "member" . }} {{ end }}
+
</div>
+
</section>
+
{{ end }}
{{ end }}
+
+
{{ define "member" }}
{{ range .Members }}
<div>
<div class="flex justify-between items-center">
···
{{ template "user/fragments/picHandleLink" . }}
<span class="ml-2 font-mono text-gray-500">{{.}}</span>
</div>
+
{{ if ne $.LoggedInUser.Did . }}
+
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
+
{{ end }}
</div>
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
{{ $repos := index $.Repos . }}
···
</div>
{{ else }}
<div class="text-gray-500 dark:text-gray-400">
+
No repositories configured yet.
</div>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
+
+
{{ define "deleteButton" }}
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Delete knot"
+
hx-delete="/knots/{{ .Domain }}"
+
hx-swap="outerHTML"
+
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
+
hx-headers='{"shouldRedirect": "true"}'
+
>
+
{{ i "trash-2" "w-5 h-5" }}
+
<span class="hidden md:inline">delete</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
+
{{ define "retryButton" }}
+
<button
+
class="btn gap-2 group"
+
title="Retry knot verification"
+
hx-post="/knots/{{ .Domain }}/retry"
+
hx-swap="none"
+
hx-headers='{"shouldRefresh": "true"}'
+
>
+
{{ i "rotate-ccw" "w-5 h-5" }}
+
<span class="hidden md:inline">retry</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
+
{{ define "removeMemberButton" }}
+
{{ $root := index . 0 }}
+
{{ $member := index . 1 }}
+
{{ $memberHandle := resolve $member }}
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Remove member"
+
hx-post="/knots/{{ $root.Registration.Domain }}/remove"
+
hx-swap="none"
+
hx-vals='{"member": "{{$member}}" }'
+
hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
+
>
+
{{ i "user-minus" "w-4 h-4" }}
+
remove
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
+6 -7
appview/pages/templates/knots/fragments/addMemberModal.html
···
{{ define "knots/fragments/addMemberModal" }}
<button
class="btn gap-2 group"
-
title="Add member to this spindle"
popovertarget="add-member-{{ .Id }}"
popovertargetaction="toggle"
>
···
{{ define "addKnotMemberPopover" }}
<form
-
hx-put="/knots/{{ .Domain }}/member"
hx-indicator="#spinner"
hx-swap="none"
class="flex flex-col gap-2"
···
<label for="member-did-{{ .Id }}" class="uppercase p-0">
ADD MEMBER
</label>
-
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p>
<input
type="text"
id="member-did-{{ .Id }}"
-
name="subject"
required
placeholder="@foo.bsky.social"
/>
<div class="flex gap-2 pt-2">
-
<button
type="button"
popovertarget="add-member-{{ .Id }}"
popovertargetaction="hide"
···
</div>
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
</form>
-
{{ end }}
-
···
{{ define "knots/fragments/addMemberModal" }}
<button
class="btn gap-2 group"
+
title="Add member to this knot"
popovertarget="add-member-{{ .Id }}"
popovertargetaction="toggle"
>
···
{{ define "addKnotMemberPopover" }}
<form
+
hx-post="/knots/{{ .Domain }}/add"
hx-indicator="#spinner"
hx-swap="none"
class="flex flex-col gap-2"
···
<label for="member-did-{{ .Id }}" class="uppercase p-0">
ADD MEMBER
</label>
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
<input
type="text"
id="member-did-{{ .Id }}"
+
name="member"
required
placeholder="@foo.bsky.social"
/>
<div class="flex gap-2 pt-2">
+
<button
type="button"
popovertarget="add-member-{{ .Id }}"
popovertargetaction="hide"
···
</div>
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
</form>
+
{{ end }}
+2 -2
appview/pages/templates/spindles/fragments/addMemberModal.html
···
id="add-member-{{ .Instance }}"
popover
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
-
{{ block "addMemberPopover" . }} {{ end }}
</div>
{{ end }}
-
{{ define "addMemberPopover" }}
<form
hx-post="/spindles/{{ .Instance }}/add"
hx-indicator="#spinner"
···
id="add-member-{{ .Instance }}"
popover
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
+
{{ block "addSpindleMemberPopover" . }} {{ end }}
</div>
{{ end }}
+
{{ define "addSpindleMemberPopover" }}
<form
hx-post="/spindles/{{ .Instance }}/add"
hx-indicator="#spinner"
-53
knotserver/middleware.go
···
-
package knotserver
-
-
import (
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
-
"net/http"
-
"time"
-
)
-
-
func (h *Handle) VerifySignature(next http.Handler) http.Handler {
-
if h.c.Server.Dev {
-
return next
-
}
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
signature := r.Header.Get("X-Signature")
-
if signature == "" || !h.verifyHMAC(signature, r) {
-
writeError(w, "signature verification failed", http.StatusForbidden)
-
return
-
}
-
next.ServeHTTP(w, r)
-
})
-
}
-
-
func (h *Handle) verifyHMAC(signature string, r *http.Request) bool {
-
secret := h.c.Server.Secret
-
timestamp := r.Header.Get("X-Timestamp")
-
if timestamp == "" {
-
return false
-
}
-
-
// Verify that the timestamp is not older than a minute
-
reqTime, err := time.Parse(time.RFC3339, timestamp)
-
if err != nil {
-
return false
-
}
-
if time.Since(reqTime) > time.Minute {
-
return false
-
}
-
-
message := r.Method + r.URL.Path + timestamp
-
-
mac := hmac.New(sha256.New, []byte(secret))
-
mac.Write([]byte(message))
-
expectedMAC := mac.Sum(nil)
-
-
signatureBytes, err := hex.DecodeString(signature)
-
if err != nil {
-
return false
-
}
-
-
return hmac.Equal(signatureBytes, expectedMAC)
-
}
···
-299
knotclient/signer.go
···
-
package knotclient
-
-
import (
-
"bytes"
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
-
"encoding/json"
-
"fmt"
-
"net/http"
-
"net/url"
-
"time"
-
-
"tangled.sh/tangled.sh/core/types"
-
)
-
-
type SignerTransport struct {
-
Secret string
-
}
-
-
func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
-
timestamp := time.Now().Format(time.RFC3339)
-
mac := hmac.New(sha256.New, []byte(s.Secret))
-
message := req.Method + req.URL.Path + timestamp
-
mac.Write([]byte(message))
-
signature := hex.EncodeToString(mac.Sum(nil))
-
req.Header.Set("X-Signature", signature)
-
req.Header.Set("X-Timestamp", timestamp)
-
return http.DefaultTransport.RoundTrip(req)
-
}
-
-
type SignedClient struct {
-
Secret string
-
Url *url.URL
-
client *http.Client
-
}
-
-
func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) {
-
client := &http.Client{
-
Timeout: 5 * time.Second,
-
Transport: SignerTransport{
-
Secret: secret,
-
},
-
}
-
-
scheme := "https"
-
if dev {
-
scheme = "http"
-
}
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
-
if err != nil {
-
return nil, err
-
}
-
-
signedClient := &SignedClient{
-
Secret: secret,
-
client: client,
-
Url: url,
-
}
-
-
return signedClient, nil
-
}
-
-
func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) {
-
return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body))
-
}
-
-
func (s *SignedClient) Init(did string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
Endpoint = "/init"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": did,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) {
-
const (
-
Method = "PUT"
-
Endpoint = "/repo/new"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": did,
-
"name": repoName,
-
"default_branch": defaultBranch,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) {
-
const (
-
Method = "GET"
-
)
-
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
-
-
body, _ := json.Marshal(map[string]any{
-
"did": ownerDid,
-
"source": source,
-
"name": name,
-
"hiddenref": hiddenRef,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
-
-
body, _ := json.Marshal(map[string]any{
-
"did": ownerDid,
-
"source": source,
-
"name": name,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
Endpoint = "/repo/fork"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": ownerDid,
-
"source": source,
-
"name": name,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
-
const (
-
Method = "DELETE"
-
Endpoint = "/repo"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": did,
-
"name": repoName,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) AddMember(did string) (*http.Response, error) {
-
const (
-
Method = "PUT"
-
Endpoint = "/member/add"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": did,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) {
-
const (
-
Method = "PUT"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
-
-
body, _ := json.Marshal(map[string]any{
-
"branch": branch,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": memberDid,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) Merge(
-
patch []byte,
-
ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string,
-
) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo)
-
-
mr := types.MergeRequest{
-
Branch: branch,
-
CommitMessage: commitMessage,
-
CommitBody: commitBody,
-
AuthorName: authorName,
-
AuthorEmail: authorEmail,
-
Patch: string(patch),
-
}
-
-
body, _ := json.Marshal(mr)
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo)
-
-
body, _ := json.Marshal(map[string]any{
-
"patch": string(patch),
-
"branch": branch,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch))
-
-
req, err := s.newRequest(Method, endpoint, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
···
-7
nix/modules/knot.nix
···
description = "DID of owner (required)";
};
-
secretFile = mkOption {
-
type = lib.types.path;
-
example = "KNOT_SERVER_SECRET=<hash>";
-
description = "File containing secret key provided by appview (required)";
-
};
-
dbPath = mkOption {
type = types.path;
default = "${cfg.stateDir}/knotserver.db";
···
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
"KNOT_SERVER_OWNER=${cfg.server.owner}"
];
-
EnvironmentFile = cfg.server.secretFile;
ExecStart = "${cfg.package}/bin/knot server";
Restart = "always";
};
···
description = "DID of owner (required)";
};
dbPath = mkOption {
type = types.path;
default = "${cfg.stateDir}/knotserver.db";
···
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
"KNOT_SERVER_OWNER=${cfg.server.owner}"
];
ExecStart = "${cfg.package}/bin/knot server";
Restart = "always";
};
+2 -2
rbac/rbac.go
···
return e.E.Enforce(user, domain, domain, "repo:create")
}
-
func (e *Enforcer) IsRepoDeleteAllowed(user, domain string) (bool, error) {
-
return e.E.Enforce(user, domain, domain, "repo:delete")
}
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
···
return e.E.Enforce(user, domain, domain, "repo:create")
}
+
func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) {
+
return e.E.Enforce(user, domain, repo, "repo:delete")
}
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+4 -7
api/tangled/pullcomment.go
···
} //
// RECORDTYPE: RepoPullComment
type RepoPullComment struct {
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
-
Body string `json:"body" cborgen:"body"`
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
-
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-
Pull string `json:"pull" cborgen:"pull"`
-
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
}
···
} //
// RECORDTYPE: RepoPullComment
type RepoPullComment struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
+
Body string `json:"body" cborgen:"body"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Pull string `json:"pull" cborgen:"pull"`
}
-11
lexicons/pulls/comment.json
···
"type": "string",
"format": "at-uri"
},
-
"repo": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"commentId": {
-
"type": "integer"
-
},
-
"owner": {
-
"type": "string",
-
"format": "did"
-
},
"body": {
"type": "string"
},
···
"type": "string",
"format": "at-uri"
},
"body": {
"type": "string"
},
+7 -2
api/tangled/repopull.go
···
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
Patch string `json:"patch" cborgen:"patch"`
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
-
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
-
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
Title string `json:"title" cborgen:"title"`
}
···
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
Sha string `json:"sha" cborgen:"sha"`
}
···
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
Patch string `json:"patch" cborgen:"patch"`
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
Title string `json:"title" cborgen:"title"`
}
···
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
Sha string `json:"sha" cborgen:"sha"`
}
+
+
// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
+
type RepoPull_Target struct {
+
Branch string `json:"branch" cborgen:"branch"`
+
Repo string `json:"repo" cborgen:"repo"`
+
}
+20 -8
lexicons/pulls/pull.json
···
"record": {
"type": "object",
"required": [
-
"targetRepo",
-
"targetBranch",
"title",
"patch",
"createdAt"
],
"properties": {
-
"targetRepo": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"targetBranch": {
-
"type": "string"
},
"title": {
"type": "string"
···
}
}
},
"source": {
"type": "object",
"required": [
···
"record": {
"type": "object",
"required": [
+
"target",
"title",
"patch",
"createdAt"
],
"properties": {
+
"target": {
+
"type": "ref",
+
"ref": "#target"
},
"title": {
"type": "string"
···
}
}
},
+
"target": {
+
"type": "object",
+
"required": [
+
"repo",
+
"branch"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"branch": {
+
"type": "string"
+
}
+
}
+
},
"source": {
"type": "object",
"required": [
+2 -2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
>
<option disabled selected>select a fork</option>
{{ range .Forks }}
-
<option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
-
{{ .Name }}
</option>
{{ end }}
</select>
···
>
<option disabled selected>select a fork</option>
{{ range .Forks }}
+
<option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
+
{{ .Did | resolve }}/{{ .Name }}
</option>
{{ end }}
</select>
+17
appview/pages/markup/sanitizer.go
···
"margin-bottom",
)
return policy
}
···
"margin-bottom",
)
+
// math
+
mathAttrs := []string{
+
"accent", "columnalign", "columnlines", "columnspan", "dir", "display",
+
"displaystyle", "encoding", "fence", "form", "largeop", "linebreak",
+
"linethickness", "lspace", "mathcolor", "mathsize", "mathvariant", "minsize",
+
"movablelimits", "notation", "rowalign", "rspace", "rowspacing", "rowspan",
+
"scriptlevel", "stretchy", "symmetric", "title", "voffset", "width",
+
}
+
mathElements := []string{
+
"annotation", "math", "menclose", "merror", "mfrac", "mi", "mmultiscripts",
+
"mn", "mo", "mover", "mpadded", "mprescripts", "mroot", "mrow", "mspace",
+
"msqrt", "mstyle", "msub", "msubsup", "msup", "mtable", "mtd", "mtext",
+
"mtr", "munder", "munderover", "semantics",
+
}
+
policy.AllowNoAttrs().OnElements(mathElements...)
+
policy.AllowAttrs(mathAttrs...).OnElements(mathElements...)
+
return policy
}
+6 -1
go.sum
···
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-
github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
···
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew=
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs=
+
github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8=
+
github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
+
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
-2
api/tangled/repoissue.go
···
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
-
IssueId int64 `json:"issueId" cborgen:"issueId"`
-
Owner string `json:"owner" cborgen:"owner"`
Repo string `json:"repo" cborgen:"repo"`
Title string `json:"title" cborgen:"title"`
}
···
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
Repo string `json:"repo" cborgen:"repo"`
Title string `json:"title" cborgen:"title"`
}
+1 -14
lexicons/issue/issue.json
···
"key": "tid",
"record": {
"type": "object",
-
"required": [
-
"repo",
-
"issueId",
-
"owner",
-
"title",
-
"createdAt"
-
],
"properties": {
"repo": {
"type": "string",
"format": "at-uri"
},
-
"issueId": {
-
"type": "integer"
-
},
-
"owner": {
-
"type": "string",
-
"format": "did"
-
},
"title": {
"type": "string"
},
···
"key": "tid",
"record": {
"type": "object",
+
"required": ["repo", "title", "createdAt"],
"properties": {
"repo": {
"type": "string",
"format": "at-uri"
},
"title": {
"type": "string"
},
+16
nix/modules/spindle.nix
···
description = "DID of owner (required)";
};
secrets = {
provider = mkOption {
type = types.str;
···
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
···
description = "DID of owner (required)";
};
+
maxJobCount = mkOption {
+
type = types.int;
+
default = 2;
+
example = 5;
+
description = "Maximum number of concurrent jobs to run";
+
};
+
+
queueSize = mkOption {
+
type = types.int;
+
default = 100;
+
example = 100;
+
description = "Maximum number of jobs queue up";
+
};
+
secrets = {
provider = mkOption {
type = types.str;
···
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
+
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+
"SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}"
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
+2 -2
appview/pages/templates/repo/compare/compare.html
···
{{ define "topbarLayout" }}
<header class="px-1 col-span-full" style="z-index: 20;">
-
{{ template "layouts/topbar" . }}
</header>
{{ end }}
···
{{ define "footerLayout" }}
<footer class="px-1 col-span-full mt-12">
-
{{ template "layouts/footer" . }}
</footer>
{{ end }}
···
{{ define "topbarLayout" }}
<header class="px-1 col-span-full" style="z-index: 20;">
+
{{ template "layouts/fragments/topbar" . }}
</header>
{{ end }}
···
{{ define "footerLayout" }}
<footer class="px-1 col-span-full mt-12">
+
{{ template "layouts/fragments/footer" . }}
</footer>
{{ end }}
+4
appview/pages/templates/repo/fragments/duration.html
···
···
+
{{ define "repo/fragments/duration" }}
+
<time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time>
+
{{ end }}
+
+4
appview/pages/templates/repo/fragments/shortTime.html
···
···
+
{{ define "repo/fragments/shortTime" }}
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }}
+
{{ end }}
+
-16
appview/pages/templates/repo/fragments/time.html
···
-
{{ define "repo/fragments/timeWrapper" }}
-
<time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time>
-
{{ end }}
-
{{ define "repo/fragments/time" }}
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }}
{{ end }}
-
-
{{ define "repo/fragments/shortTime" }}
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }}
-
{{ end }}
-
-
{{ define "repo/fragments/shortTimeAgo" }}
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
-
{{ end }}
-
-
{{ define "repo/fragments/duration" }}
-
<time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time>
-
{{ end }}
···
{{ define "repo/fragments/time" }}
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }}
{{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
···
···
+
{{ define "repo/fragments/timeWrapper" }}
+
<time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time>
+
{{ end }}
+
+
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
···
</div>
{{ end }}
<div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2">
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
</div>
</div>
</a>
···
</div>
{{ end }}
<div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2">
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
</div>
</div>
</a>
+35
appview/pages/cache.go
···
···
+
package pages
+
+
import (
+
"sync"
+
)
+
+
type TmplCache[K comparable, V any] struct {
+
data map[K]V
+
mutex sync.RWMutex
+
}
+
+
func NewTmplCache[K comparable, V any]() *TmplCache[K, V] {
+
return &TmplCache[K, V]{
+
data: make(map[K]V),
+
}
+
}
+
+
func (c *TmplCache[K, V]) Get(key K) (V, bool) {
+
c.mutex.RLock()
+
defer c.mutex.RUnlock()
+
val, exists := c.data[key]
+
return val, exists
+
}
+
+
func (c *TmplCache[K, V]) Set(key K, value V) {
+
c.mutex.Lock()
+
defer c.mutex.Unlock()
+
c.data[key] = value
+
}
+
+
func (c *TmplCache[K, V]) Size() int {
+
c.mutex.RLock()
+
defer c.mutex.RUnlock()
+
return len(c.data)
+
}
+1 -1
appview/pages/templates/user/fragments/editBio.html
···
<label class="m-0 p-0" for="description">bio</label>
<textarea
type="text"
-
class="py-1 px-1 w-full"
name="description"
rows="3"
placeholder="write a bio">{{ $description }}</textarea>
···
<label class="m-0 p-0" for="description">bio</label>
<textarea
type="text"
+
class="p-2 w-full"
name="description"
rows="3"
placeholder="write a bio">{{ $description }}</textarea>
+19
appview/pages/templates/user/starred.html
···
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
+
+
{{ define "profileContent" }}
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
+
{{ block "starredRepos" . }}{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "starredRepos" }}
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Repos }}
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "user/fragments/repoCard" (list $ . true) }}
+
</div>
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not have any starred repos yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+2 -2
appview/pages/templates/user/fragments/profileCard.html
···
{{ with $root }}
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
<span class="select-none after:content-['ยท']"></span>
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
</div>
{{ end }}
{{ end }}
···
{{ with $root }}
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span>
<span class="select-none after:content-['ยท']"></span>
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span>
</div>
{{ end }}
{{ end }}
+45
appview/pages/templates/user/strings.html
···
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }}
+
+
{{ define "profileContent" }}
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
+
{{ block "allStrings" . }}{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "allStrings" }}
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Strings }}
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "singleString" (list $ .) }}
+
</div>
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "singleString" }}
+
{{ $root := index . 0 }}
+
{{ $s := index . 1 }}
+
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
+
<div class="font-medium dark:text-white flex gap-2 items-center">
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
+
</div>
+
{{ with $s.Description }}
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
+
{{ . }}
+
</div>
+
{{ end }}
+
+
{{ $stat := $s.Stats }}
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
+
<span class="select-none [&:before]:content-['ยท']"></span>
+
{{ with $s.Edited }}
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
+
{{ else }}
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+22 -2
knotserver/routes.go
···
}
var modVer string
for _, mod := range info.Deps {
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
-
version = mod.Version
break
}
}
if modVer == "" {
-
version = "unknown"
}
}
···
}
var modVer string
+
var sha string
+
var modified bool
+
for _, mod := range info.Deps {
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
+
modVer = mod.Version
break
}
}
+
for _, setting := range info.Settings {
+
switch setting.Key {
+
case "vcs.revision":
+
sha = setting.Value
+
case "vcs.modified":
+
modified = setting.Value == "true"
+
}
+
}
+
if modVer == "" {
+
modVer = "unknown"
+
}
+
+
if sha == "" {
+
version = modVer
+
} else if modified {
+
version = fmt.Sprintf("%s (%s with modifications)", modVer, sha)
+
} else {
+
version = fmt.Sprintf("%s (%s)", modVer, sha)
}
}
+1 -1
patchutil/combinediff.go
···
// we have f1 and f2, combine them
combined, err := combineFiles(f1, f2)
if err != nil {
-
fmt.Println(err)
}
// combined can be nil commit 2 reverted all changes from commit 1
···
// we have f1 and f2, combine them
combined, err := combineFiles(f1, f2)
if err != nil {
+
// fmt.Println(err)
}
// combined can be nil commit 2 reverted all changes from commit 1
+1 -1
appview/pages/templates/errors/knot404.html
···
The repository you were looking for could not be found. The knot serving the repository may be unavailable.
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline">
{{ i "arrow-left" "w-4 h-4" }}
back to timeline
</a>
···
The repository you were looking for could not be found. The knot serving the repository may be unavailable.
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
+
<a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline">
{{ i "arrow-left" "w-4 h-4" }}
back to timeline
</a>
+25
appview/pages/templates/timeline/fragments/trending.html
···
···
+
{{ define "timeline/fragments/trending" }}
+
<div class="w-full md:mx-0 py-4">
+
<div class="px-6 pb-4">
+
<h3 class="text-xl font-bold dark:text-white flex items-center gap-2">
+
Trending
+
{{ i "trending-up" "size-4 flex-shrink-0" }}
+
</h3>
+
</div>
+
<div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch">
+
{{ range $index, $repo := .Repos }}
+
<div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96">
+
{{ template "user/fragments/repoCard" (list $ $repo true) }}
+
</div>
+
{{ else }}
+
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
+
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
+
No trending repositories this week
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
+109
legal/terms.md
···
···
+
# Terms of Service
+
+
**Last updated:** January 15, 2025
+
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
+
to and use of the Tangled platform and services (the "Service")
+
operated by us ("Tangled," "we," "us," or "our").
+
+
## 1. Acceptance of Terms
+
+
By accessing or using our Service, you agree to be bound by these Terms.
+
If you disagree with any part of these terms, then you may not access
+
the Service.
+
+
## 2. Account Registration
+
+
To use certain features of the Service, you must register for an
+
account. You agree to provide accurate, current, and complete
+
information during the registration process and to update such
+
information to keep it accurate, current, and complete.
+
+
## 3. Account Termination
+
+
> **Important Notice**
+
>
+
> **We reserve the right to terminate, suspend, or restrict access to
+
> your account at any time, for any reason, or for no reason at all, at
+
> our sole discretion.** This includes, but is not limited to,
+
> termination for violation of these Terms, inappropriate conduct, spam,
+
> abuse, or any other behavior we deem harmful to the Service or other
+
> users.
+
>
+
> Account termination may result in the loss of access to your
+
> repositories, data, and other content associated with your account. We
+
> are not obligated to provide advance notice of termination, though we
+
> may do so in our discretion.
+
+
## 4. Acceptable Use
+
+
You agree not to use the Service to:
+
+
- Violate any applicable laws or regulations
+
- Infringe upon the rights of others
+
- Upload, store, or share content that is illegal, harmful, threatening,
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
+
objectionable
+
- Engage in spam, phishing, or other deceptive practices
+
- Attempt to gain unauthorized access to the Service or other users'
+
accounts
+
- Interfere with or disrupt the Service or servers connected to the
+
Service
+
+
## 5. Content and Intellectual Property
+
+
You retain ownership of the content you upload to the Service. By
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
+
license to use, reproduce, modify, and distribute your content as
+
necessary to provide the Service.
+
+
## 6. Privacy
+
+
Your privacy is important to us. Please review our [Privacy
+
Policy](/privacy), which also governs your use of the Service.
+
+
## 7. Disclaimers
+
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
+
no warranties, expressed or implied, and hereby disclaim and negate all
+
other warranties including without limitation, implied warranties or
+
conditions of merchantability, fitness for a particular purpose, or
+
non-infringement of intellectual property or other violation of rights.
+
+
## 8. Limitation of Liability
+
+
In no event shall Tangled, nor its directors, employees, partners,
+
agents, suppliers, or affiliates, be liable for any indirect,
+
incidental, special, consequential, or punitive damages, including
+
without limitation, loss of profits, data, use, goodwill, or other
+
intangible losses, resulting from your use of the Service.
+
+
## 9. Indemnification
+
+
You agree to defend, indemnify, and hold harmless Tangled and its
+
affiliates, officers, directors, employees, and agents from and against
+
any and all claims, damages, obligations, losses, liabilities, costs,
+
or debt, and expenses (including attorney's fees).
+
+
## 10. Governing Law
+
+
These Terms shall be interpreted and governed by the laws of Finland,
+
without regard to its conflict of law provisions.
+
+
## 11. Changes to Terms
+
+
We reserve the right to modify or replace these Terms at any time. If a
+
revision is material, we will try to provide at least 30 days notice
+
prior to any new terms taking effect.
+
+
## 12. Contact Information
+
+
If you have any questions about these Terms of Service, please contact
+
us through our platform or via email.
+
+
---
+
+
These terms are effective as of the last updated date shown above and
+
will remain in effect except with respect to any changes in their
+
provisions in the future, which will be in effect immediately after
+
being posted on this page.
+2 -2
appview/pages/templates/user/settings/profile.html
···
<div class="p-6">
<p class="text-xl font-bold dark:text-white">Settings</p>
</div>
-
<div class="bg-white dark:bg-gray-800">
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
<div class="col-span-1">
{{ template "user/settings/fragments/sidebar" . }}
</div>
···
<div class="p-6">
<p class="text-xl font-bold dark:text-white">Settings</p>
</div>
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="col-span-1">
{{ template "user/settings/fragments/sidebar" . }}
</div>
+53
api/tangled/knotlistKeys.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.knot.listKeys
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
KnotListKeysNSID = "sh.tangled.knot.listKeys"
+
)
+
+
// KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call.
+
type KnotListKeys_Output struct {
+
// cursor: Pagination cursor for next page
+
Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
+
Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"`
+
}
+
+
// KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema.
+
type KnotListKeys_PublicKey struct {
+
// createdAt: Key upload timestamp
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
// did: DID associated with the public key
+
Did string `json:"did" cborgen:"did"`
+
// key: Public key contents
+
Key string `json:"key" cborgen:"key"`
+
}
+
+
// KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys".
+
//
+
// cursor: Pagination cursor
+
// limit: Maximum number of keys to return
+
func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) {
+
var out KnotListKeys_Output
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+41
api/tangled/repoarchive.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.archive
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoArchiveNSID = "sh.tangled.repo.archive"
+
)
+
+
// RepoArchive calls the XRPC method "sh.tangled.repo.archive".
+
//
+
// format: Archive format
+
// prefix: Prefix for files in the archive
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if format != "" {
+
params["format"] = format
+
}
+
if prefix != "" {
+
params["prefix"] = prefix
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+80
api/tangled/repoblob.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.blob
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoBlobNSID = "sh.tangled.repo.blob"
+
)
+
+
// RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema.
+
type RepoBlob_LastCommit struct {
+
Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
+
// hash: Commit hash
+
Hash string `json:"hash" cborgen:"hash"`
+
// message: Commit message
+
Message string `json:"message" cborgen:"message"`
+
// shortHash: Short commit hash
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
+
// when: Commit timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
+
type RepoBlob_Output struct {
+
// content: File content (base64 encoded for binary files)
+
Content string `json:"content" cborgen:"content"`
+
// encoding: Content encoding
+
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
+
// isBinary: Whether the file is binary
+
IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"`
+
LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"`
+
// mimeType: MIME type of the file
+
MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"`
+
// path: The file path
+
Path string `json:"path" cborgen:"path"`
+
// ref: The git reference used
+
Ref string `json:"ref" cborgen:"ref"`
+
// size: File size in bytes
+
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
+
}
+
+
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
+
type RepoBlob_Signature struct {
+
// email: Author email
+
Email string `json:"email" cborgen:"email"`
+
// name: Author name
+
Name string `json:"name" cborgen:"name"`
+
// when: Author timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
+
//
+
// path: Path to the file within the repository
+
// raw: Return raw file content instead of JSON response
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) {
+
var out RepoBlob_Output
+
+
params := map[string]interface{}{}
+
params["path"] = path
+
if raw {
+
params["raw"] = raw
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+59
api/tangled/repobranch.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.branch
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoBranchNSID = "sh.tangled.repo.branch"
+
)
+
+
// RepoBranch_Output is the output of a sh.tangled.repo.branch call.
+
type RepoBranch_Output struct {
+
Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
+
// hash: Latest commit hash on this branch
+
Hash string `json:"hash" cborgen:"hash"`
+
// isDefault: Whether this is the default branch
+
IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"`
+
// message: Latest commit message
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
+
// name: Branch name
+
Name string `json:"name" cborgen:"name"`
+
// shortHash: Short commit hash
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
+
// when: Timestamp of latest commit
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema.
+
type RepoBranch_Signature struct {
+
// email: Author email
+
Email string `json:"email" cborgen:"email"`
+
// name: Author name
+
Name string `json:"name" cborgen:"name"`
+
// when: Author timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBranch calls the XRPC method "sh.tangled.repo.branch".
+
//
+
// name: Branch name to get information for
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) {
+
var out RepoBranch_Output
+
+
params := map[string]interface{}{}
+
params["name"] = name
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+39
api/tangled/repobranches.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.branches
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoBranchesNSID = "sh.tangled.repo.branches"
+
)
+
+
// RepoBranches calls the XRPC method "sh.tangled.repo.branches".
+
//
+
// cursor: Pagination cursor
+
// limit: Maximum number of branches to return
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+35
api/tangled/repocompare.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.compare
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoCompareNSID = "sh.tangled.repo.compare"
+
)
+
+
// RepoCompare calls the XRPC method "sh.tangled.repo.compare".
+
//
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
// rev1: First revision (commit, branch, or tag)
+
// rev2: Second revision (commit, branch, or tag)
+
func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
params["repo"] = repo
+
params["rev1"] = rev1
+
params["rev2"] = rev2
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+33
api/tangled/repodiff.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.diff
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoDiffNSID = "sh.tangled.repo.diff"
+
)
+
+
// RepoDiff calls the XRPC method "sh.tangled.repo.diff".
+
//
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+55
api/tangled/repogetDefaultBranch.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.getDefaultBranch
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch"
+
)
+
+
// RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call.
+
type RepoGetDefaultBranch_Output struct {
+
Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
+
// hash: Latest commit hash on default branch
+
Hash string `json:"hash" cborgen:"hash"`
+
// message: Latest commit message
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
+
// name: Default branch name
+
Name string `json:"name" cborgen:"name"`
+
// shortHash: Short commit hash
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
+
// when: Timestamp of latest commit
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema.
+
type RepoGetDefaultBranch_Signature struct {
+
// email: Author email
+
Email string `json:"email" cborgen:"email"`
+
// name: Author name
+
Name string `json:"name" cborgen:"name"`
+
// when: Author timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch".
+
//
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) {
+
var out RepoGetDefaultBranch_Output
+
+
params := map[string]interface{}{}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+61
api/tangled/repolanguages.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.languages
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoLanguagesNSID = "sh.tangled.repo.languages"
+
)
+
+
// RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema.
+
type RepoLanguages_Language struct {
+
// color: Hex color code for this language
+
Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
+
// extensions: File extensions associated with this language
+
Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"`
+
// fileCount: Number of files in this language
+
FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"`
+
// name: Programming language name
+
Name string `json:"name" cborgen:"name"`
+
// percentage: Percentage of total codebase (0-100)
+
Percentage int64 `json:"percentage" cborgen:"percentage"`
+
// size: Total size of files in this language (bytes)
+
Size int64 `json:"size" cborgen:"size"`
+
}
+
+
// RepoLanguages_Output is the output of a sh.tangled.repo.languages call.
+
type RepoLanguages_Output struct {
+
Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"`
+
// ref: The git reference used
+
Ref string `json:"ref" cborgen:"ref"`
+
// totalFiles: Total number of files analyzed
+
TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"`
+
// totalSize: Total size of all analyzed files in bytes
+
TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"`
+
}
+
+
// RepoLanguages calls the XRPC method "sh.tangled.repo.languages".
+
//
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) {
+
var out RepoLanguages_Output
+
+
params := map[string]interface{}{}
+
if ref != "" {
+
params["ref"] = ref
+
}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+45
api/tangled/repolog.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.log
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoLogNSID = "sh.tangled.repo.log"
+
)
+
+
// RepoLog calls the XRPC method "sh.tangled.repo.log".
+
//
+
// cursor: Pagination cursor (commit SHA)
+
// limit: Maximum number of commits to return
+
// path: Path to filter commits by
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
if path != "" {
+
params["path"] = path
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+39
api/tangled/repotags.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.tags
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoTagsNSID = "sh.tangled.repo.tags"
+
)
+
+
// RepoTags calls the XRPC method "sh.tangled.repo.tags".
+
//
+
// cursor: Pagination cursor
+
// limit: Maximum number of tags to return
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+73
lexicons/knot/listKeys.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.knot.listKeys",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "List all public keys stored in the knot server",
+
"parameters": {
+
"type": "params",
+
"properties": {
+
"limit": {
+
"type": "integer",
+
"description": "Maximum number of keys to return",
+
"minimum": 1,
+
"maximum": 1000,
+
"default": 100
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["keys"],
+
"properties": {
+
"keys": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#publicKey"
+
}
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor for next page"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "InternalServerError",
+
"description": "Failed to retrieve public keys"
+
}
+
]
+
},
+
"publicKey": {
+
"type": "object",
+
"required": ["did", "key", "createdAt"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID associated with the public key"
+
},
+
"key": {
+
"type": "string",
+
"maxLength": 4096,
+
"description": "Public key contents"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Key upload timestamp"
+
}
+
}
+
}
+
}
+
}
+55
lexicons/repo/archive.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.archive",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "ref"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)"
+
},
+
"format": {
+
"type": "string",
+
"description": "Archive format",
+
"enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"],
+
"default": "tar.gz"
+
},
+
"prefix": {
+
"type": "string",
+
"description": "Prefix for files in the archive"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*",
+
"description": "Binary archive data"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
},
+
{
+
"name": "ArchiveError",
+
"description": "Failed to create archive"
+
}
+
]
+
}
+
}
+
}
+138
lexicons/repo/blob.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.blob",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "ref", "path"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)"
+
},
+
"path": {
+
"type": "string",
+
"description": "Path to the file within the repository"
+
},
+
"raw": {
+
"type": "boolean",
+
"description": "Return raw file content instead of JSON response",
+
"default": false
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["ref", "path", "content"],
+
"properties": {
+
"ref": {
+
"type": "string",
+
"description": "The git reference used"
+
},
+
"path": {
+
"type": "string",
+
"description": "The file path"
+
},
+
"content": {
+
"type": "string",
+
"description": "File content (base64 encoded for binary files)"
+
},
+
"encoding": {
+
"type": "string",
+
"description": "Content encoding",
+
"enum": ["utf-8", "base64"]
+
},
+
"size": {
+
"type": "integer",
+
"description": "File size in bytes"
+
},
+
"isBinary": {
+
"type": "boolean",
+
"description": "Whether the file is binary"
+
},
+
"mimeType": {
+
"type": "string",
+
"description": "MIME type of the file"
+
},
+
"lastCommit": {
+
"type": "ref",
+
"ref": "#lastCommit"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "FileNotFound",
+
"description": "File not found at the specified path"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
},
+
"lastCommit": {
+
"type": "object",
+
"required": ["hash", "message", "when"],
+
"properties": {
+
"hash": {
+
"type": "string",
+
"description": "Commit hash"
+
},
+
"shortHash": {
+
"type": "string",
+
"description": "Short commit hash"
+
},
+
"message": {
+
"type": "string",
+
"description": "Commit message"
+
},
+
"author": {
+
"type": "ref",
+
"ref": "#signature"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Commit timestamp"
+
}
+
}
+
},
+
"signature": {
+
"type": "object",
+
"required": ["name", "email", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Author name"
+
},
+
"email": {
+
"type": "string",
+
"description": "Author email"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Author timestamp"
+
}
+
}
+
}
+
}
+
}
+94
lexicons/repo/branch.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.branch",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "name"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"name": {
+
"type": "string",
+
"description": "Branch name to get information for"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["name", "hash", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Branch name"
+
},
+
"hash": {
+
"type": "string",
+
"description": "Latest commit hash on this branch"
+
},
+
"shortHash": {
+
"type": "string",
+
"description": "Short commit hash"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp of latest commit"
+
},
+
"message": {
+
"type": "string",
+
"description": "Latest commit message"
+
},
+
"author": {
+
"type": "ref",
+
"ref": "#signature"
+
},
+
"isDefault": {
+
"type": "boolean",
+
"description": "Whether this is the default branch"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "BranchNotFound",
+
"description": "Branch not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
},
+
"signature": {
+
"type": "object",
+
"required": ["name", "email", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Author name"
+
},
+
"email": {
+
"type": "string",
+
"description": "Author email"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Author timestamp"
+
}
+
}
+
}
+
}
+
}
+43
lexicons/repo/branches.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.branches",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"limit": {
+
"type": "integer",
+
"description": "Maximum number of branches to return",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
}
+
}
+
}
+49
lexicons/repo/compare.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.compare",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "rev1", "rev2"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"rev1": {
+
"type": "string",
+
"description": "First revision (commit, branch, or tag)"
+
},
+
"rev2": {
+
"type": "string",
+
"description": "Second revision (commit, branch, or tag)"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*",
+
"description": "Compare output in application/json"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RevisionNotFound",
+
"description": "One or both revisions not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
},
+
{
+
"name": "CompareError",
+
"description": "Failed to compare revisions"
+
}
+
]
+
}
+
}
+
}
+40
lexicons/repo/diff.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.diff",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "ref"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
}
+
}
+
}
+82
lexicons/repo/getDefaultBranch.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.getDefaultBranch",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["name", "hash", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Default branch name"
+
},
+
"hash": {
+
"type": "string",
+
"description": "Latest commit hash on default branch"
+
},
+
"shortHash": {
+
"type": "string",
+
"description": "Short commit hash"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp of latest commit"
+
},
+
"message": {
+
"type": "string",
+
"description": "Latest commit message"
+
},
+
"author": {
+
"type": "ref",
+
"ref": "#signature"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
},
+
"signature": {
+
"type": "object",
+
"required": ["name", "email", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Author name"
+
},
+
"email": {
+
"type": "string",
+
"description": "Author email"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Author timestamp"
+
}
+
}
+
}
+
}
+
}
+99
lexicons/repo/languages.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.languages",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)",
+
"default": "HEAD"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["ref", "languages"],
+
"properties": {
+
"ref": {
+
"type": "string",
+
"description": "The git reference used"
+
},
+
"languages": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#language"
+
}
+
},
+
"totalSize": {
+
"type": "integer",
+
"description": "Total size of all analyzed files in bytes"
+
},
+
"totalFiles": {
+
"type": "integer",
+
"description": "Total number of files analyzed"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
},
+
"language": {
+
"type": "object",
+
"required": ["name", "size", "percentage"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Programming language name"
+
},
+
"size": {
+
"type": "integer",
+
"description": "Total size of files in this language (bytes)"
+
},
+
"percentage": {
+
"type": "integer",
+
"description": "Percentage of total codebase (0-100)"
+
},
+
"fileCount": {
+
"type": "integer",
+
"description": "Number of files in this language"
+
},
+
"color": {
+
"type": "string",
+
"description": "Hex color code for this language"
+
},
+
"extensions": {
+
"type": "array",
+
"items": {
+
"type": "string"
+
},
+
"description": "File extensions associated with this language"
+
}
+
}
+
}
+
}
+
}
+60
lexicons/repo/log.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.log",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "ref"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)"
+
},
+
"path": {
+
"type": "string",
+
"description": "Path to filter commits by",
+
"default": ""
+
},
+
"limit": {
+
"type": "integer",
+
"description": "Maximum number of commits to return",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor (commit SHA)"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "PathNotFound",
+
"description": "Path not found in repository"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
}
+
}
+
}
+43
lexicons/repo/tags.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.tags",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"limit": {
+
"type": "integer",
+
"description": "Maximum number of tags to return",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
}
+
}
+
}
-285
knotclient/unsigned.go
···
-
package knotclient
-
-
import (
-
"bytes"
-
"encoding/json"
-
"fmt"
-
"io"
-
"log"
-
"net/http"
-
"net/url"
-
"strconv"
-
"time"
-
-
"tangled.sh/tangled.sh/core/types"
-
)
-
-
type UnsignedClient struct {
-
Url *url.URL
-
client *http.Client
-
}
-
-
func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) {
-
client := &http.Client{
-
Timeout: 5 * time.Second,
-
}
-
-
scheme := "https"
-
if dev {
-
scheme = "http"
-
}
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
-
if err != nil {
-
return nil, err
-
}
-
-
unsignedClient := &UnsignedClient{
-
client: client,
-
Url: url,
-
}
-
-
return unsignedClient, nil
-
}
-
-
func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) {
-
reqUrl := us.Url.JoinPath(endpoint)
-
-
// add query parameters
-
if query != nil {
-
reqUrl.RawQuery = query.Encode()
-
}
-
-
return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body))
-
}
-
-
func do[T any](us *UnsignedClient, req *http.Request) (*T, error) {
-
resp, err := us.client.Do(req)
-
if err != nil {
-
return nil, err
-
}
-
defer resp.Body.Close()
-
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Printf("Error reading response body: %v", err)
-
return nil, err
-
}
-
-
var result T
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Printf("Error unmarshalling response body: %v", err)
-
return nil, err
-
}
-
-
return &result, nil
-
}
-
-
func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref)
-
if ref == "" {
-
endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName)
-
}
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoIndexResponse](us, req)
-
}
-
-
func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref))
-
-
query := url.Values{}
-
query.Add("page", strconv.Itoa(page))
-
query.Add("per_page", strconv.Itoa(60))
-
-
req, err := us.newRequest(Method, endpoint, query, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoLogResponse](us, req)
-
}
-
-
func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName)
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoBranchesResponse](us, req)
-
}
-
-
func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName)
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoTagsResponse](us, req)
-
}
-
-
func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch))
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoBranchResponse](us, req)
-
}
-
-
func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
resp, err := us.client.Do(req)
-
if err != nil {
-
return nil, err
-
}
-
defer resp.Body.Close()
-
-
var defaultBranch types.RepoDefaultBranchResponse
-
if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil {
-
return nil, err
-
}
-
-
return &defaultBranch, nil
-
}
-
-
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
-
const (
-
Method = "GET"
-
Endpoint = "/capabilities"
-
)
-
-
req, err := us.newRequest(Method, Endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
resp, err := us.client.Do(req)
-
if err != nil {
-
return nil, err
-
}
-
defer resp.Body.Close()
-
-
var capabilities types.Capabilities
-
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
-
return nil, err
-
}
-
-
return &capabilities, nil
-
}
-
-
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2))
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, fmt.Errorf("Failed to create request.")
-
}
-
-
compareResp, err := us.client.Do(req)
-
if err != nil {
-
return nil, fmt.Errorf("Failed to create request.")
-
}
-
defer compareResp.Body.Close()
-
-
switch compareResp.StatusCode {
-
case 404:
-
case 400:
-
return nil, fmt.Errorf("Branch comparisons not supported on this knot.")
-
}
-
-
respBody, err := io.ReadAll(compareResp.Body)
-
if err != nil {
-
log.Println("failed to compare across branches")
-
return nil, fmt.Errorf("Failed to compare branches.")
-
}
-
defer compareResp.Body.Close()
-
-
var formatPatchResponse types.RepoFormatPatchResponse
-
err = json.Unmarshal(respBody, &formatPatchResponse)
-
if err != nil {
-
log.Println("failed to unmarshal format-patch response", err)
-
return nil, fmt.Errorf("failed to compare branches.")
-
}
-
-
return &formatPatchResponse, nil
-
}
-
-
func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) {
-
const (
-
Method = "GET"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref))
-
-
req, err := s.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
resp, err := s.client.Do(req)
-
if err != nil {
-
return nil, err
-
}
-
-
var result types.RepoLanguageResponse
-
if resp.StatusCode != http.StatusOK {
-
log.Println("failed to calculate languages", resp.Status)
-
return &types.RepoLanguageResponse{}, nil
-
}
-
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
return nil, err
-
}
-
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
return nil, err
-
}
-
-
return &result, nil
-
}
···
+2 -2
knotserver/events.go
···
WriteBufferSize: 1024,
}
-
func (h *Handle) Events(w http.ResponseWriter, r *http.Request) {
l := h.l.With("handler", "OpLog")
l.Debug("received new connection")
···
}
}
-
func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error {
events, err := h.db.GetEvents(*cursor)
if err != nil {
h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
···
WriteBufferSize: 1024,
}
+
func (h *Knot) Events(w http.ResponseWriter, r *http.Request) {
l := h.l.With("handler", "OpLog")
l.Debug("received new connection")
···
}
}
+
func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error {
events, err := h.db.GetEvents(*cursor)
if err != nil {
h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
+30
api/tangled/tangledowner.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.owner
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
OwnerNSID = "sh.tangled.owner"
+
)
+
+
// Owner_Output is the output of a sh.tangled.owner call.
+
type Owner_Output struct {
+
Owner string `json:"owner" cborgen:"owner"`
+
}
+
+
// Owner calls the XRPC method "sh.tangled.owner".
+
func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) {
+
var out Owner_Output
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+31
lexicons/owner.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.owner",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get the owner of a service",
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"owner"
+
],
+
"properties": {
+
"owner": {
+
"type": "string",
+
"format": "did"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "OwnerNotFound",
+
"description": "Owner is not set for this service"
+
}
+
]
+
}
+
}
+
}
-12
appview/pages/templates/knots/fragments/bannerRequiresUpgrade.html
···
-
{{ define "knots/fragments/bannerRequiresUpgrade" }}
-
<div class="px-6 py-2">
-
The following knots that you administer will have to be upgraded to be compatible with the latest version of Tangled:
-
<ul class="list-disc mx-12 my-2">
-
{{range $i, $r := .Registrations}}
-
<li>{{ $r.Domain }}</li>
-
{{ end }}
-
</ul>
-
Repositories hosted on these knots will not be accessible until upgraded.
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.8.0.md">Click to read the upgrade guide</a>.
-
</div>
-
{{ end }}
···
+30
api/tangled/knotversion.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.knot.version
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
KnotVersionNSID = "sh.tangled.knot.version"
+
)
+
+
// KnotVersion_Output is the output of a sh.tangled.knot.version call.
+
type KnotVersion_Output struct {
+
Version string `json:"version" cborgen:"version"`
+
}
+
+
// KnotVersion calls the XRPC method "sh.tangled.knot.version".
+
func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) {
+
var out KnotVersion_Output
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+25
lexicons/knot/version.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.knot.version",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get the version of a knot",
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"version"
+
],
+
"properties": {
+
"version": {
+
"type": "string"
+
}
+
}
+
}
+
},
+
"errors": []
+
}
+
}
+
}
+6 -1
appview/pages/templates/spindles/fragments/spindleListing.html
···
{{ define "spindleRightSide" }}
<div id="right-side" class="flex gap-2">
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
-
{{ if .Verified }}
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
{{ template "spindles/fragments/addMemberModal" . }}
{{ else }}
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
{{ block "spindleRetryButton" . }} {{ end }}
{{ end }}
{{ block "spindleDeleteButton" . }} {{ end }}
</div>
{{ end }}
···
{{ define "spindleRightSide" }}
<div id="right-side" class="flex gap-2">
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
+
+
{{ if .NeedsUpgrade }}
+
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} needs upgrade </span>
+
{{ block "spindleRetryButton" . }} {{ end }}
+
{{ else if .Verified }}
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
{{ template "spindles/fragments/addMemberModal" . }}
{{ else }}
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
{{ block "spindleRetryButton" . }} {{ end }}
{{ end }}
+
{{ block "spindleDeleteButton" . }} {{ end }}
</div>
{{ end }}
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
{{ define "repo/issues/fragments/editIssueComment" }}
-
{{ with .Comment }}
-
<div id="comment-container-{{.CommentId}}">
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
-
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
-
<!-- show user "hats" -->
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
-
{{ if $isIssueAuthor }}
-
<span class="before:content-['ยท']"></span>
-
author
-
{{ end }}
-
-
<span class="before:content-['ยท']"></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
-
id="{{ .CommentId }}">
-
{{ template "repo/fragments/time" .Created }}
-
</a>
-
-
<button
-
class="btn px-2 py-1 flex items-center gap-2 text-sm group"
-
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
-
hx-include="#edit-textarea-{{ .CommentId }}"
-
hx-target="#comment-container-{{ .CommentId }}"
-
hx-swap="outerHTML">
-
{{ i "check" "w-4 h-4" }}
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
<button
-
class="btn px-2 py-1 flex items-center gap-2 text-sm"
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
-
hx-target="#comment-container-{{ .CommentId }}"
-
hx-swap="outerHTML">
-
{{ i "x" "w-4 h-4" }}
-
</button>
-
<span id="comment-{{.CommentId}}-status"></span>
-
</div>
-
<div>
-
<textarea
-
id="edit-textarea-{{ .CommentId }}"
-
name="body"
-
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
-
</div>
</div>
-
{{ end }}
{{ end }}
···
{{ define "repo/issues/fragments/editIssueComment" }}
+
<div id="comment-body-{{.Comment.Id}}" class="pt-2">
+
<textarea
+
id="edit-textarea-{{ .Comment.Id }}"
+
name="body"
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
+
rows="5"
+
autofocus>{{ .Comment.Body }}</textarea>
+
{{ template "editActions" $ }}
+
</div>
+
{{ end }}
+
{{ define "editActions" }}
+
<div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2">
+
{{ template "cancel" . }}
+
{{ template "save" . }}
</div>
{{ end }}
+
{{ define "save" }}
+
<button
+
class="btn-create py-0 flex gap-1 items-center group text-sm"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
+
hx-include="#edit-textarea-{{ .Comment.Id }}"
+
hx-target="#comment-body-{{ .Comment.Id }}"
+
hx-swap="outerHTML">
+
{{ i "check" "size-4" }}
+
save
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
{{ define "cancel" }}
+
<button
+
class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
+
hx-target="#comment-body-{{ .Comment.Id }}"
+
hx-swap="outerHTML">
+
{{ i "x" "size-4" }}
+
cancel
+
</button>
+
{{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
···
-
{{ define "repo/issues/fragments/issueComment" }}
-
{{ with .Comment }}
-
<div id="comment-container-{{.CommentId}}">
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
-
-
<!-- show user "hats" -->
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
-
{{ if $isIssueAuthor }}
-
<span class="before:content-['ยท']"></span>
-
author
-
{{ end }}
-
-
<span class="before:content-['ยท']"></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
-
id="{{ .CommentId }}">
-
{{ if .Deleted }}
-
deleted {{ template "repo/fragments/time" .Deleted }}
-
{{ else if .Edited }}
-
edited {{ template "repo/fragments/time" .Edited }}
-
{{ else }}
-
{{ template "repo/fragments/time" .Created }}
-
{{ end }}
-
</a>
-
-
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
-
{{ if and $isCommentOwner (not .Deleted) }}
-
<button
-
class="btn px-2 py-1 text-sm"
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
-
hx-swap="outerHTML"
-
hx-target="#comment-container-{{.CommentId}}"
-
>
-
{{ i "pencil" "w-4 h-4" }}
-
</button>
-
<button
-
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
-
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
-
hx-confirm="Are you sure you want to delete your comment?"
-
hx-swap="outerHTML"
-
hx-target="#comment-container-{{.CommentId}}"
-
>
-
{{ i "trash-2" "w-4 h-4" }}
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
-
-
</div>
-
{{ if not .Deleted }}
-
<div class="prose dark:prose-invert">
-
{{ .Body | markdown }}
-
</div>
-
{{ end }}
-
</div>
-
{{ end }}
-
{{ end }}
···
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
···
···
+
{{ define "repo/issues/fragments/issueCommentBody" }}
+
<div id="comment-body-{{.Comment.Id}}">
+
{{ if not .Comment.Deleted }}
+
<div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div>
+
{{ else }}
+
<div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div>
+
{{ end }}
+
</div>
+
{{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
···
···
+
{{ define "repo/issues/fragments/putIssue" }}
+
<!-- this form is used for new and edit, .Issue is passed when editing -->
+
<form
+
{{ if eq .Action "edit" }}
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
+
{{ else }}
+
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
+
{{ end }}
+
hx-swap="none"
+
hx-indicator="#spinner">
+
<div class="flex flex-col gap-2">
+
<div>
+
<label for="title">title</label>
+
<input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" />
+
</div>
+
<div>
+
<label for="body">body</label>
+
<textarea
+
name="body"
+
id="body"
+
rows="6"
+
class="w-full resize-y"
+
placeholder="Describe your issue. Markdown is supported."
+
>{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
+
</div>
+
<div class="flex justify-between">
+
<div id="issues" class="error"></div>
+
<div class="flex gap-2 items-center">
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
+
type="button"
+
{{ if .Issue }}
+
href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}"
+
{{ else }}
+
href="/{{ .RepoInfo.FullName }}/issues"
+
{{ end }}
+
>
+
{{ i "x" "w-4 h-4" }}
+
cancel
+
</a>
+
<button type="submit" class="btn-create flex items-center gap-2">
+
{{ if eq .Action "edit" }}
+
{{ i "pencil" "w-4 h-4" }}
+
{{ .Action }} issue
+
{{ else }}
+
{{ i "circle-plus" "w-4 h-4" }}
+
{{ .Action }} issue
+
{{ end }}
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
</div>
+
</div>
+
</form>
+
{{ end }}
+1 -33
appview/pages/templates/repo/issues/new.html
···
{{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
-
<form
-
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
-
class="space-y-6"
-
hx-swap="none"
-
hx-indicator="#spinner"
-
>
-
<div class="flex flex-col gap-4">
-
<div>
-
<label for="title">title</label>
-
<input type="text" name="title" id="title" class="w-full" />
-
</div>
-
<div>
-
<label for="body">body</label>
-
<textarea
-
name="body"
-
id="body"
-
rows="6"
-
class="w-full resize-y"
-
placeholder="Describe your issue. Markdown is supported."
-
></textarea>
-
</div>
-
<div>
-
<button type="submit" class="btn-create flex items-center gap-2">
-
{{ i "circle-plus" "w-4 h-4" }}
-
create issue
-
<span id="spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</div>
-
</div>
-
<div id="issues" class="error"></div>
-
</form>
{{ end }}
···
{{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
+
{{ template "repo/issues/fragments/putIssue" . }}
{{ end }}
+8 -2
nix/gomod2nix.toml
···
[mod."github.com/whyrusleeping/cbor-gen"]
version = "v0.3.1"
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
[mod."github.com/yuin/goldmark"]
-
version = "v1.4.15"
-
hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0="
[mod."github.com/yuin/goldmark-highlighting/v2"]
version = "v2.0.0-20230729083705-37449abec8cc"
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
···
[mod."github.com/whyrusleeping/cbor-gen"]
version = "v0.3.1"
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
+
[mod."github.com/wyatt915/goldmark-treeblood"]
+
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
+
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
+
[mod."github.com/wyatt915/treeblood"]
+
version = "v0.1.15"
+
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
[mod."github.com/yuin/goldmark"]
+
version = "v1.7.12"
+
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
[mod."github.com/yuin/goldmark-highlighting/v2"]
version = "v2.0.0-20230729083705-37449abec8cc"
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+5
xrpc/errors/errors.go
···
WithMessage("failed to access repository"),
)
var AuthError = func(err error) XrpcError {
return NewXrpcError(
WithTag("Auth"),
···
WithMessage("failed to access repository"),
)
+
var RefNotFoundError = NewXrpcError(
+
WithTag("RefNotFound"),
+
WithMessage("failed to access ref"),
+
)
+
var AuthError = func(err error) XrpcError {
return NewXrpcError(
WithTag("Auth"),
+15
flake.lock
···
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
···
},
"root": {
"inputs": {
"gomod2nix": "gomod2nix",
"htmx-src": "htmx-src",
"htmx-ws-src": "htmx-ws-src",
···
{
"nodes": {
+
"flake-compat": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1751685974,
+
"narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=",
+
"rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1",
+
"type": "tarball",
+
"url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1"
+
},
+
"original": {
+
"type": "tarball",
+
"url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"
+
}
+
},
"flake-utils": {
"inputs": {
"systems": "systems"
···
},
"root": {
"inputs": {
+
"flake-compat": "flake-compat",
"gomod2nix": "gomod2nix",
"htmx-src": "htmx-src",
"htmx-ws-src": "htmx-ws-src",
+44
contrib/Tiltfile
···
···
+
common_env = {
+
"TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""),
+
"TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""),
+
"TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"),
+
"TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"),
+
}
+
+
nix_globs = ["nix/**", "flake.nix", "flake.lock"]
+
+
local_resource(
+
name="appview",
+
serve_cmd="nix run .#watch-appview",
+
serve_dir="..",
+
deps=nix_globs,
+
env=common_env,
+
allow_parallel=True,
+
)
+
+
local_resource(
+
name="tailwind",
+
serve_cmd="nix run .#watch-tailwind",
+
serve_dir="..",
+
deps=nix_globs,
+
env=common_env,
+
allow_parallel=True,
+
)
+
+
local_resource(
+
name="redis",
+
serve_cmd="redis-server",
+
serve_dir="..",
+
deps=nix_globs,
+
env=common_env,
+
allow_parallel=True,
+
)
+
+
local_resource(
+
name="vm",
+
serve_cmd="nix run --impure .#vm",
+
serve_dir="..",
+
deps=nix_globs,
+
env=common_env,
+
allow_parallel=True,
+
)
+1
flake.nix
···
nativeBuildInputs = [
pkgs.go
pkgs.air
pkgs.gopls
pkgs.httpie
pkgs.litecli
···
nativeBuildInputs = [
pkgs.go
pkgs.air
+
pkgs.tilt
pkgs.gopls
pkgs.httpie
pkgs.litecli
+90
appview/pages/templates/fragments/multiline-select.html
···
···
+
{{ define "fragments/multiline-select" }}
+
<script>
+
function highlight(scroll = false) {
+
document.querySelectorAll(".hl").forEach(el => {
+
el.classList.remove("hl");
+
});
+
+
const hash = window.location.hash;
+
if (!hash || !hash.startsWith("#L")) {
+
return;
+
}
+
+
const rangeStr = hash.substring(2);
+
const parts = rangeStr.split("-");
+
let startLine, endLine;
+
+
if (parts.length === 2) {
+
startLine = parseInt(parts[0], 10);
+
endLine = parseInt(parts[1], 10);
+
} else {
+
startLine = parseInt(parts[0], 10);
+
endLine = startLine;
+
}
+
+
if (isNaN(startLine) || isNaN(endLine)) {
+
console.log("nan");
+
console.log(startLine);
+
console.log(endLine);
+
return;
+
}
+
+
let target = null;
+
+
for (let i = startLine; i<= endLine; i++) {
+
const idEl = document.getElementById(`L${i}`);
+
if (idEl) {
+
const el = idEl.closest(".line");
+
if (el) {
+
el.classList.add("hl");
+
target = el;
+
}
+
}
+
}
+
+
if (scroll && target) {
+
target.scrollIntoView({
+
behavior: "smooth",
+
block: "center",
+
});
+
}
+
}
+
+
document.addEventListener("DOMContentLoaded", () => {
+
console.log("DOMContentLoaded");
+
highlight(true);
+
});
+
window.addEventListener("hashchange", () => {
+
console.log("hashchange");
+
highlight();
+
});
+
window.addEventListener("popstate", () => {
+
console.log("popstate");
+
highlight();
+
});
+
+
const lineNumbers = document.querySelectorAll('a[href^="#L"');
+
let startLine = null;
+
+
lineNumbers.forEach(el => {
+
el.addEventListener("click", (event) => {
+
event.preventDefault();
+
const currentLine = parseInt(el.href.split("#L")[1]);
+
+
if (event.shiftKey && startLine !== null) {
+
const endLine = currentLine;
+
const min = Math.min(startLine, endLine);
+
const max = Math.max(startLine, endLine);
+
const newHash = `#L${min}-${max}`;
+
history.pushState(null, '', newHash);
+
} else {
+
const newHash = `#L${currentLine}`;
+
history.pushState(null, '', newHash);
+
startLine = currentLine;
+
}
+
+
highlight();
+
});
+
});
+
</script>
+
{{ end }}
+56
appview/pages/templates/fragments/dolly/logo.html
···
···
+
{{ define "fragments/dolly/logo" }}
+
<svg
+
version="1.1"
+
id="svg1"
+
class="{{.}}"
+
width="25"
+
height="25"
+
viewBox="0 0 25 25"
+
sodipodi:docname="tangled_dolly_face_only.png"
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+
xmlns:xlink="http://www.w3.org/1999/xlink"
+
xmlns="http://www.w3.org/2000/svg"
+
xmlns:svg="http://www.w3.org/2000/svg">
+
<title>Dolly</title>
+
<defs
+
id="defs1" />
+
<sodipodi:namedview
+
id="namedview1"
+
pagecolor="#ffffff"
+
bordercolor="#000000"
+
borderopacity="0.25"
+
inkscape:showpageshadow="2"
+
inkscape:pageopacity="0.0"
+
inkscape:pagecheckerboard="true"
+
inkscape:deskcolor="#d5d5d5">
+
<inkscape:page
+
x="0"
+
y="0"
+
width="25"
+
height="25"
+
id="page2"
+
margin="0"
+
bleed="0" />
+
</sodipodi:namedview>
+
<g
+
inkscape:groupmode="layer"
+
inkscape:label="Image"
+
id="g1">
+
<image
+
width="252.48"
+
height="248.96001"
+
preserveAspectRatio="none"
+
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;"
+
id="image1"
+
x="-233.6257"
+
y="10.383364"
+
style="display:none" />
+
<path
+
fill="currentColor"
+
style="stroke-width:0.111183"
+
d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z"
+
id="path4" />
+
</g>
+
</svg>
+
{{ end }}
+57
appview/pages/templates/fragments/dolly/silhouette.html
···
···
+
{{ define "fragments/dolly/silhouette" }}
+
<svg
+
version="1.1"
+
id="svg1"
+
width="32"
+
height="32"
+
viewBox="0 0 25 25"
+
sodipodi:docname="tangled_dolly_silhouette.png"
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+
xmlns="http://www.w3.org/2000/svg"
+
xmlns:svg="http://www.w3.org/2000/svg">
+
<style>
+
.dolly {
+
color: #000000;
+
}
+
+
@media (prefers-color-scheme: dark) {
+
.dolly {
+
color: #ffffff;
+
}
+
}
+
</style>
+
<title>Dolly</title>
+
<defs
+
id="defs1" />
+
<sodipodi:namedview
+
id="namedview1"
+
pagecolor="#ffffff"
+
bordercolor="#000000"
+
borderopacity="0.25"
+
inkscape:showpageshadow="2"
+
inkscape:pageopacity="0.0"
+
inkscape:pagecheckerboard="true"
+
inkscape:deskcolor="#d1d1d1">
+
<inkscape:page
+
x="0"
+
y="0"
+
width="25"
+
height="25"
+
id="page2"
+
margin="0"
+
bleed="0" />
+
</sodipodi:namedview>
+
<g
+
inkscape:groupmode="layer"
+
inkscape:label="Image"
+
id="g1">
+
<path
+
class="dolly"
+
fill="currentColor"
+
style="stroke-width:1.12248"
+
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
+
id="path1" />
+
</g>
+
</svg>
+
{{ end }}
+9
appview/pages/templates/fragments/logotypeSmall.html
···
···
+
{{ define "fragments/logotypeSmall" }}
+
<span class="flex items-center gap-2">
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
+
<span class="font-bold text-xl not-italic">tangled</span>
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
+
alpha
+
</span>
+
<span>
+
{{ end }}
+1 -1
appview/pages/templates/repo/commit.html
···
{{ define "extrameta" }}
{{ $title := printf "commit %s &middot; %s" .Diff.Commit.This .RepoInfo.FullName }}
-
{{ $url := printf "https://tangled.sh/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }}
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
···
{{ define "extrameta" }}
{{ $title := printf "commit %s &middot; %s" .Diff.Commit.This .RepoInfo.FullName }}
+
{{ $url := printf "https://tangled.org/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }}
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
+5 -5
appview/pages/templates/repo/fragments/meta.html
···
{{ define "repo/fragments/meta" }}
<meta
name="vcs:clone"
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
/>
<meta
name="forge:summary"
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
/>
<meta
name="forge:dir"
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
/>
<meta
name="forge:file"
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
/>
<meta
name="forge:line"
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
/>
<meta
name="go-import"
···
{{ define "repo/fragments/meta" }}
<meta
name="vcs:clone"
+
content="https://tangled.org/{{ .RepoInfo.FullName }}"
/>
<meta
name="forge:summary"
+
content="https://tangled.org/{{ .RepoInfo.FullName }}"
/>
<meta
name="forge:dir"
+
content="https://tangled.org/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
/>
<meta
name="forge:file"
+
content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
/>
<meta
name="forge:line"
+
content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
/>
<meta
name="go-import"
+2 -2
appview/pages/templates/repo/pipelines/pipelines.html
···
{{ define "extrameta" }}
{{ $title := "pipelines"}}
-
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
···
<span class="inline-flex gap-2 items-center">
<span class="font-bold">{{ $target }}</span>
{{ i "arrow-left" "size-4" }}
-
{{ .Trigger.PRSourceBranch }}
<span class="text-sm font-mono">
@
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
···
{{ define "extrameta" }}
{{ $title := "pipelines"}}
+
{{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }}
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
···
<span class="inline-flex gap-2 items-center">
<span class="font-bold">{{ $target }}</span>
{{ i "arrow-left" "size-4" }}
+
{{ .Trigger.PRSourceBranch }}
<span class="text-sm font-mono">
@
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
+1 -1
appview/pages/templates/repo/pipelines/workflow.html
···
{{ define "extrameta" }}
{{ $title := "pipelines"}}
-
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
···
{{ define "extrameta" }}
{{ $title := "pipelines"}}
+
{{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }}
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
+2 -2
appview/pages/templates/repo/pulls/interdiff.html
···
{{ define "extrameta" }}
{{ $title := printf "interdiff of %d and %d &middot; %s &middot; pull #%d &middot; %s" .Round (sub .Round 1) .Pull.Title .Pull.PullId .RepoInfo.FullName }}
-
{{ $url := printf "https://tangled.sh/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
-
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" (unescapeHtml $title) "Url" $url) }}
{{ end }}
···
{{ define "extrameta" }}
{{ $title := printf "interdiff of %d and %d &middot; %s &middot; pull #%d &middot; %s" .Round (sub .Round 1) .Pull.Title .Pull.PullId .RepoInfo.FullName }}
+
{{ $url := printf "https://tangled.org/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" (unescapeHtml $title) "Url" $url) }}
{{ end }}
+1 -1
appview/pages/templates/spindles/index.html
···
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
<span class="flex items-center gap-1">
{{ i "book" "w-3 h-3" }}
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a>
</span>
</div>
···
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
<span class="flex items-center gap-1">
{{ i "book" "w-3 h-3" }}
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a>
</span>
</div>
+1 -1
appview/pages/templates/strings/dashboard.html
···
{{ define "extrameta" }}
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
<meta property="og:type" content="profile" />
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
{{ end }}
···
{{ define "extrameta" }}
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
<meta property="og:type" content="profile" />
+
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" />
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
{{ end }}
+1 -1
appview/pages/templates/strings/string.html
···
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
<meta property="og:type" content="object" />
-
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
<meta property="og:description" content="{{ .String.Description }}" />
{{ end }}
···
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
<meta property="og:type" content="object" />
+
<meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
<meta property="og:description" content="{{ .String.Description }}" />
{{ end }}
+1 -2
appview/pages/templates/timeline/fragments/hero.html
···
</div>
<figure class="w-full hidden md:block md:w-auto">
-
<a href="https://tangled.sh/@tangled.sh/core" class="block">
<img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" />
</a>
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
···
</figure>
</div>
{{ end }}
-
···
</div>
<figure class="w-full hidden md:block md:w-auto">
+
<a href="https://tangled.org/@tangled.org/core" class="block">
<img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" />
</a>
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
···
</figure>
</div>
{{ end }}
+1 -2
appview/pages/templates/timeline/home.html
···
{{ define "extrameta" }}
<meta property="og:title" content="timeline ยท tangled" />
<meta property="og:type" content="object" />
-
<meta property="og:url" content="https://tangled.sh" />
<meta property="og:description" content="tightly-knit social coding" />
{{ end }}
···
) }}
</div>
{{ end }}
-
···
{{ define "extrameta" }}
<meta property="og:title" content="timeline ยท tangled" />
<meta property="og:type" content="object" />
+
<meta property="og:url" content="https://tangled.org" />
<meta property="og:description" content="tightly-knit social coding" />
{{ end }}
···
) }}
</div>
{{ end }}
+1 -1
appview/cache/session/store.go
···
"fmt"
"time"
-
"tangled.sh/tangled.sh/core/appview/cache"
)
type OAuthSession struct {
···
"fmt"
"time"
+
"tangled.org/core/appview/cache"
)
type OAuthSession struct {
+4 -4
appview/oauth/oauth.go
···
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/gorilla/sessions"
oauth "tangled.sh/icyphox.sh/atproto-oauth"
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
-
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
-
"tangled.sh/tangled.sh/core/appview/config"
-
"tangled.sh/tangled.sh/core/appview/oauth/client"
-
xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient"
)
type OAuth struct {
···
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/gorilla/sessions"
+
sessioncache "tangled.org/core/appview/cache/session"
+
"tangled.org/core/appview/config"
+
"tangled.org/core/appview/oauth/client"
+
xrpc "tangled.org/core/appview/xrpcclient"
oauth "tangled.sh/icyphox.sh/atproto-oauth"
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
)
type OAuth struct {
+1 -1
appview/pipelines/router.go
···
"net/http"
"github.com/go-chi/chi/v5"
-
"tangled.sh/tangled.sh/core/appview/middleware"
)
func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
···
"net/http"
"github.com/go-chi/chi/v5"
+
"tangled.org/core/appview/middleware"
)
func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
+1 -1
appview/pulls/router.go
···
"net/http"
"github.com/go-chi/chi/v5"
-
"tangled.sh/tangled.sh/core/appview/middleware"
)
func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
···
"net/http"
"github.com/go-chi/chi/v5"
+
"tangled.org/core/appview/middleware"
)
func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
+4 -4
appview/serververify/verify.go
···
"fmt"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
-
"tangled.sh/tangled.sh/core/rbac"
)
var (
···
"fmt"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/db"
+
"tangled.org/core/appview/xrpcclient"
+
"tangled.org/core/rbac"
)
var (
+1 -1
cmd/combinediff/main.go
···
"os"
"github.com/bluekeyes/go-gitdiff/gitdiff"
-
"tangled.sh/tangled.sh/core/patchutil"
)
func main() {
···
"os"
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
"tangled.org/core/patchutil"
)
func main() {
+1 -1
cmd/interdiff/main.go
···
"os"
"github.com/bluekeyes/go-gitdiff/gitdiff"
-
"tangled.sh/tangled.sh/core/patchutil"
)
func main() {
···
"os"
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
"tangled.org/core/patchutil"
)
func main() {
+5 -5
cmd/knot/main.go
···
"os"
"github.com/urfave/cli/v3"
-
"tangled.sh/tangled.sh/core/guard"
-
"tangled.sh/tangled.sh/core/hook"
-
"tangled.sh/tangled.sh/core/keyfetch"
-
"tangled.sh/tangled.sh/core/knotserver"
-
"tangled.sh/tangled.sh/core/log"
)
func main() {
···
"os"
"github.com/urfave/cli/v3"
+
"tangled.org/core/guard"
+
"tangled.org/core/hook"
+
"tangled.org/core/keyfetch"
+
"tangled.org/core/knotserver"
+
"tangled.org/core/log"
)
func main() {
+2 -2
docs/knot-hosting.md
···
First, clone this repository:
```
-
git clone https://tangled.sh/@tangled.sh/core
```
Then, build the `knot` CLI. This is the knot administration and operation tool.
···
You should now have a running knot server! You can finalize
your registration by hitting the `verify` button on the
-
[/knots](https://tangled.sh/knots) page. This simply creates
a record on your PDS to announce the existence of the knot.
### custom paths
···
First, clone this repository:
```
+
git clone https://tangled.org/@tangled.org/core
```
Then, build the `knot` CLI. This is the knot administration and operation tool.
···
You should now have a running knot server! You can finalize
your registration by hitting the `verify` button on the
+
[/knots](https://tangled.org/knots) page. This simply creates
a record on your PDS to announce the existence of the knot.
### custom paths
+4 -5
docs/migrations.md
···
For knots:
- Upgrade to latest tag (v1.9.0 or above)
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
hit the "retry" button to verify your knot
For spindles:
- Upgrade to latest tag (v1.9.0 or above)
- Head to the [spindle
-
dashboard](https://tangled.sh/spindles) and hit the
"retry" button to verify your spindle
## Upgrading from v1.7.x
···
environment variable entirely
- `KNOT_SERVER_OWNER` is now required on boot, set this to
your DID. You can find your DID in the
-
[settings](https://tangled.sh/settings) page.
- Restart your knot once you have replaced the environment
variable
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
hit the "retry" button to verify your knot. This simply
writes a `sh.tangled.knot` record to your PDS.
···
};
};
```
-
···
For knots:
- Upgrade to latest tag (v1.9.0 or above)
+
- Head to the [knot dashboard](https://tangled.org/knots) and
hit the "retry" button to verify your knot
For spindles:
- Upgrade to latest tag (v1.9.0 or above)
- Head to the [spindle
+
dashboard](https://tangled.org/spindles) and hit the
"retry" button to verify your spindle
## Upgrading from v1.7.x
···
environment variable entirely
- `KNOT_SERVER_OWNER` is now required on boot, set this to
your DID. You can find your DID in the
+
[settings](https://tangled.org/settings) page.
- Restart your knot once you have replaced the environment
variable
+
- Head to the [knot dashboard](https://tangled.org/knots) and
hit the "retry" button to verify your knot. This simply
writes a `sh.tangled.knot` record to your PDS.
···
};
};
```
+1 -1
keyfetch/keyfetch.go
···
"strings"
"github.com/urfave/cli/v3"
-
"tangled.sh/tangled.sh/core/log"
)
func Command() *cli.Command {
···
"strings"
"github.com/urfave/cli/v3"
+
"tangled.org/core/log"
)
func Command() *cli.Command {
+1 -1
knotserver/db/events.go
···
"fmt"
"time"
-
"tangled.sh/tangled.sh/core/notifier"
)
type Event struct {
···
"fmt"
"time"
+
"tangled.org/core/notifier"
)
type Event struct {
+2 -2
knotserver/git/diff.go
···
"github.com/bluekeyes/go-gitdiff/gitdiff"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
-
"tangled.sh/tangled.sh/core/patchutil"
-
"tangled.sh/tangled.sh/core/types"
)
func (g *GitRepo) Diff() (*types.NiceDiff, error) {
···
"github.com/bluekeyes/go-gitdiff/gitdiff"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
+
"tangled.org/core/patchutil"
+
"tangled.org/core/types"
)
func (g *GitRepo) Diff() (*types.NiceDiff, error) {
+1 -1
knotserver/git/post_receive.go
···
"strings"
"time"
-
"tangled.sh/tangled.sh/core/api/tangled"
"github.com/go-git/go-git/v5/plumbing"
)
···
"strings"
"time"
+
"tangled.org/core/api/tangled"
"github.com/go-git/go-git/v5/plumbing"
)
+1 -1
knotserver/git/tree.go
···
"time"
"github.com/go-git/go-git/v5/plumbing/object"
-
"tangled.sh/tangled.sh/core/types"
)
func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
···
"time"
"github.com/go-git/go-git/v5/plumbing/object"
+
"tangled.org/core/types"
)
func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
+8 -8
knotserver/internal.go
···
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/hook"
-
"tangled.sh/tangled.sh/core/knotserver/config"
-
"tangled.sh/tangled.sh/core/knotserver/db"
-
"tangled.sh/tangled.sh/core/knotserver/git"
-
"tangled.sh/tangled.sh/core/notifier"
-
"tangled.sh/tangled.sh/core/rbac"
-
"tangled.sh/tangled.sh/core/workflow"
)
type InternalHandle struct {
···
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/hook"
+
"tangled.org/core/knotserver/config"
+
"tangled.org/core/knotserver/db"
+
"tangled.org/core/knotserver/git"
+
"tangled.org/core/notifier"
+
"tangled.org/core/rbac"
+
"tangled.org/core/workflow"
)
type InternalHandle struct {
+3 -3
knotserver/xrpc/delete_repo.go
···
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/xrpc"
securejoin "github.com/cyphar/filepath-securejoin"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/rbac"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/xrpc"
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/rbac"
+
xrpcerr "tangled.org/core/xrpc/errors"
)
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/list_keys.go
···
"net/http"
"strconv"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
···
"net/http"
"strconv"
+
"tangled.org/core/api/tangled"
+
xrpcerr "tangled.org/core/xrpc/errors"
)
func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
+6 -6
knotserver/xrpc/merge.go
···
"github.com/bluesky-social/indigo/atproto/syntax"
securejoin "github.com/cyphar/filepath-securejoin"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/knotserver/git"
-
"tangled.sh/tangled.sh/core/patchutil"
-
"tangled.sh/tangled.sh/core/rbac"
-
"tangled.sh/tangled.sh/core/types"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
···
"github.com/bluesky-social/indigo/atproto/syntax"
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/knotserver/git"
+
"tangled.org/core/patchutil"
+
"tangled.org/core/rbac"
+
"tangled.org/core/types"
+
xrpcerr "tangled.org/core/xrpc/errors"
)
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/owner.go
···
import (
"net/http"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
···
import (
"net/http"
+
"tangled.org/core/api/tangled"
+
xrpcerr "tangled.org/core/xrpc/errors"
)
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_branch.go
···
"net/url"
"time"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/knotserver/git"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
···
"net/url"
"time"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/knotserver/git"
+
xrpcerr "tangled.org/core/xrpc/errors"
)
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_compare.go
···
"fmt"
"net/http"
-
"tangled.sh/tangled.sh/core/knotserver/git"
-
"tangled.sh/tangled.sh/core/types"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
···
"fmt"
"net/http"
+
"tangled.org/core/knotserver/git"
+
"tangled.org/core/types"
+
xrpcerr "tangled.org/core/xrpc/errors"
)
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_diff.go
···
import (
"net/http"
-
"tangled.sh/tangled.sh/core/knotserver/git"
-
"tangled.sh/tangled.sh/core/types"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
···
import (
"net/http"
+
"tangled.org/core/knotserver/git"
+
"tangled.org/core/types"
+
xrpcerr "tangled.org/core/xrpc/errors"
)
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_languages.go
···
"net/http"
"time"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/knotserver/git"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
···
"net/http"
"time"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/knotserver/git"
+
xrpcerr "tangled.org/core/xrpc/errors"
)
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_tags.go
···
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
-
"tangled.sh/tangled.sh/core/knotserver/git"
-
"tangled.sh/tangled.sh/core/types"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
···
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
+
"tangled.org/core/knotserver/git"
+
"tangled.org/core/types"
+
xrpcerr "tangled.org/core/xrpc/errors"
)
func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/version.go
···
"net/http"
"runtime/debug"
-
"tangled.sh/tangled.sh/core/api/tangled"
)
// version is set during build time.
···
var modified bool
for _, mod := range info.Deps {
-
if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
modVer = mod.Version
break
}
···
"net/http"
"runtime/debug"
+
"tangled.org/core/api/tangled"
)
// version is set during build time.
···
var modified bool
for _, mod := range info.Deps {
+
if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" {
modVer = mod.Version
break
}
+9 -9
knotserver/xrpc/xrpc.go
···
"strings"
securejoin "github.com/cyphar/filepath-securejoin"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/idresolver"
-
"tangled.sh/tangled.sh/core/jetstream"
-
"tangled.sh/tangled.sh/core/knotserver/config"
-
"tangled.sh/tangled.sh/core/knotserver/db"
-
"tangled.sh/tangled.sh/core/notifier"
-
"tangled.sh/tangled.sh/core/rbac"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
"github.com/go-chi/chi/v5"
)
···
"strings"
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/idresolver"
+
"tangled.org/core/jetstream"
+
"tangled.org/core/knotserver/config"
+
"tangled.org/core/knotserver/db"
+
"tangled.org/core/notifier"
+
"tangled.org/core/rbac"
+
xrpcerr "tangled.org/core/xrpc/errors"
+
"tangled.org/core/xrpc/serviceauth"
"github.com/go-chi/chi/v5"
)
+1 -1
lexicon-build-config.json
···
"package": "tangled",
"prefix": "sh.tangled",
"outdir": "api/tangled",
-
"import": "tangled.sh/tangled.sh/core/api/tangled",
"gen-server": true
}
]
···
"package": "tangled",
"prefix": "sh.tangled",
"outdir": "api/tangled",
+
"import": "tangled.org/core/api/tangled",
"gen-server": true
}
]
+1 -1
patchutil/patchutil.go
···
"strings"
"github.com/bluekeyes/go-gitdiff/gitdiff"
-
"tangled.sh/tangled.sh/core/types"
)
func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
···
"strings"
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
"tangled.org/core/types"
)
func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
+4 -4
spindle/db/events.go
···
"fmt"
"time"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/notifier"
-
"tangled.sh/tangled.sh/core/spindle/models"
-
"tangled.sh/tangled.sh/core/tid"
)
type Event struct {
···
"fmt"
"time"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/notifier"
+
"tangled.org/core/spindle/models"
+
"tangled.org/core/tid"
)
type Event struct {
+5 -5
spindle/engine/engine.go
···
securejoin "github.com/cyphar/filepath-securejoin"
"golang.org/x/sync/errgroup"
-
"tangled.sh/tangled.sh/core/notifier"
-
"tangled.sh/tangled.sh/core/spindle/config"
-
"tangled.sh/tangled.sh/core/spindle/db"
-
"tangled.sh/tangled.sh/core/spindle/models"
-
"tangled.sh/tangled.sh/core/spindle/secrets"
)
var (
···
securejoin "github.com/cyphar/filepath-securejoin"
"golang.org/x/sync/errgroup"
+
"tangled.org/core/notifier"
+
"tangled.org/core/spindle/config"
+
"tangled.org/core/spindle/db"
+
"tangled.org/core/spindle/models"
+
"tangled.org/core/spindle/secrets"
)
var (
+2 -2
spindle/engines/nixery/setup_steps.go
···
"path"
"strings"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/workflow"
)
func nixConfStep() Step {
···
"path"
"strings"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/workflow"
)
func nixConfStep() Step {
+1 -1
spindle/stream.go
···
"strconv"
"time"
-
"tangled.sh/tangled.sh/core/spindle/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
···
"strconv"
"time"
+
"tangled.org/core/spindle/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
+9 -9
spindle/xrpc/xrpc.go
···
"github.com/go-chi/chi/v5"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/idresolver"
-
"tangled.sh/tangled.sh/core/rbac"
-
"tangled.sh/tangled.sh/core/spindle/config"
-
"tangled.sh/tangled.sh/core/spindle/db"
-
"tangled.sh/tangled.sh/core/spindle/models"
-
"tangled.sh/tangled.sh/core/spindle/secrets"
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
)
const ActorDid string = "ActorDid"
···
"github.com/go-chi/chi/v5"
+
"tangled.org/core/api/tangled"
+
"tangled.org/core/idresolver"
+
"tangled.org/core/rbac"
+
"tangled.org/core/spindle/config"
+
"tangled.org/core/spindle/db"
+
"tangled.org/core/spindle/models"
+
"tangled.org/core/spindle/secrets"
+
xrpcerr "tangled.org/core/xrpc/errors"
+
"tangled.org/core/xrpc/serviceauth"
)
const ActorDid string = "ActorDid"
+1 -1
workflow/def.go
···
"slices"
"strings"
-
"tangled.sh/tangled.sh/core/api/tangled"
"github.com/go-git/go-git/v5/plumbing"
"gopkg.in/yaml.v3"
···
"slices"
"strings"
+
"tangled.org/core/api/tangled"
"github.com/go-git/go-git/v5/plumbing"
"gopkg.in/yaml.v3"
+42
api/tangled/labeldefinition.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.label.definition
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
LabelDefinitionNSID = "sh.tangled.label.definition"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.label.definition", &LabelDefinition{})
+
} //
+
// RECORDTYPE: LabelDefinition
+
type LabelDefinition struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.label.definition" cborgen:"$type,const=sh.tangled.label.definition"`
+
// color: The hex value for the background color for the label. Appviews may choose to respect this.
+
Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
// multiple: Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]
+
Multiple *bool `json:"multiple,omitempty" cborgen:"multiple,omitempty"`
+
// name: The display name of this label.
+
Name string `json:"name" cborgen:"name"`
+
// scope: The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.
+
Scope []string `json:"scope" cborgen:"scope"`
+
// valueType: The type definition of this label. Appviews may allow sorting for certain types.
+
ValueType *LabelDefinition_ValueType `json:"valueType" cborgen:"valueType"`
+
}
+
+
// LabelDefinition_ValueType is a "valueType" in the sh.tangled.label.definition schema.
+
type LabelDefinition_ValueType struct {
+
// enum: Closed set of values that this label can take.
+
Enum []string `json:"enum,omitempty" cborgen:"enum,omitempty"`
+
// format: An optional constraint that can be applied on string concrete types.
+
Format string `json:"format" cborgen:"format"`
+
// type: The concrete type of this label's value.
+
Type string `json:"type" cborgen:"type"`
+
}
+89
lexicons/label/definition.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.label.definition",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "any",
+
"record": {
+
"type": "object",
+
"required": [
+
"name",
+
"valueType",
+
"scope",
+
"createdAt"
+
],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "The display name of this label.",
+
"minGraphemes": 1,
+
"maxGraphemes": 40
+
},
+
"valueType": {
+
"type": "ref",
+
"ref": "#valueType",
+
"description": "The type definition of this label. Appviews may allow sorting for certain types."
+
},
+
"scope": {
+
"type": "array",
+
"description": "The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.",
+
"items": {
+
"type": "string",
+
"format": "nsid"
+
}
+
},
+
"color": {
+
"type": "string",
+
"description": "The hex value for the background color for the label. Appviews may choose to respect this."
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"multiple": {
+
"type": "boolean",
+
"description": "Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]"
+
}
+
}
+
}
+
},
+
"valueType": {
+
"type": "object",
+
"required": [
+
"type",
+
"format"
+
],
+
"properties": {
+
"type": {
+
"type": "string",
+
"enum": [
+
"null",
+
"boolean",
+
"integer",
+
"string"
+
],
+
"description": "The concrete type of this label's value."
+
},
+
"format": {
+
"type": "string",
+
"enum": [
+
"any",
+
"did",
+
"nsid"
+
],
+
"description": "An optional constraint that can be applied on string concrete types."
+
},
+
"enum": {
+
"type": "array",
+
"description": "Closed set of values that this label can take.",
+
"items": {
+
"type": "string"
+
}
+
}
+
}
+
}
+
}
+
}
+34
api/tangled/labelop.go
···
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.label.op
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
LabelOpNSID = "sh.tangled.label.op"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.label.op", &LabelOp{})
+
} //
+
// RECORDTYPE: LabelOp
+
type LabelOp struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.label.op" cborgen:"$type,const=sh.tangled.label.op"`
+
Add []*LabelOp_Operand `json:"add" cborgen:"add"`
+
Delete []*LabelOp_Operand `json:"delete" cborgen:"delete"`
+
PerformedAt string `json:"performedAt" cborgen:"performedAt"`
+
// subject: The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op.
+
Subject string `json:"subject" cborgen:"subject"`
+
}
+
+
// LabelOp_Operand is a "operand" in the sh.tangled.label.op schema.
+
type LabelOp_Operand struct {
+
// key: ATURI to the label definition
+
Key string `json:"key" cborgen:"key"`
+
// value: Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value.
+
Value string `json:"value" cborgen:"value"`
+
}
+2
cmd/gen.go
···
tangled.KnotMember{},
tangled.LabelDefinition{},
tangled.LabelDefinition_ValueType{},
tangled.Pipeline{},
tangled.Pipeline_CloneOpts{},
tangled.Pipeline_ManualTriggerData{},
···
tangled.KnotMember{},
tangled.LabelDefinition{},
tangled.LabelDefinition_ValueType{},
+
tangled.LabelOp{},
+
tangled.LabelOp_Operand{},
tangled.Pipeline{},
tangled.Pipeline_CloneOpts{},
tangled.Pipeline_ManualTriggerData{},
+64
lexicons/label/op.json
···
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.label.op",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"subject",
+
"add",
+
"delete",
+
"performedAt"
+
],
+
"properties": {
+
"subject": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op."
+
},
+
"performedAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"add": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#operand"
+
}
+
},
+
"delete": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#operand"
+
}
+
}
+
}
+
}
+
},
+
"operand": {
+
"type": "object",
+
"required": [
+
"key",
+
"value"
+
],
+
"properties": {
+
"key": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "ATURI to the label definition"
+
},
+
"value": {
+
"type": "string",
+
"description": "Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value."
+
}
+
}
+
}
+
}
+
}
-36
knotserver/util.go
···
package knotserver
import (
-
"net/http"
-
"os"
-
"path/filepath"
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
securejoin "github.com/cyphar/filepath-securejoin"
-
"github.com/go-chi/chi/v5"
)
-
func didPath(r *http.Request) string {
-
did := chi.URLParam(r, "did")
-
name := chi.URLParam(r, "name")
-
path, _ := securejoin.SecureJoin(did, name)
-
filepath.Clean(path)
-
return path
-
}
-
-
func getDescription(path string) (desc string) {
-
db, err := os.ReadFile(filepath.Join(path, "description"))
-
if err == nil {
-
desc = string(db)
-
} else {
-
desc = ""
-
}
-
return
-
}
-
func setContentDisposition(w http.ResponseWriter, name string) {
-
h := "inline; filename=\"" + name + "\""
-
w.Header().Add("Content-Disposition", h)
-
}
-
-
func setGZipMIME(w http.ResponseWriter) {
-
setMIME(w, "application/gzip")
-
}
-
-
func setMIME(w http.ResponseWriter, mime string) {
-
w.Header().Add("Content-Type", mime)
-
}
-
var TIDClock = syntax.NewTIDClock(0)
func TID() string {
···
package knotserver
import (
"github.com/bluesky-social/indigo/atproto/syntax"
)
var TIDClock = syntax.NewTIDClock(0)
func TID() string {
+8 -5
lexicons/repo/repo.json
···
"required": [
"name",
"knot",
-
"owner",
"createdAt"
],
"properties": {
···
"type": "string",
"description": "name of the repo"
},
-
"owner": {
-
"type": "string",
-
"format": "did"
-
},
"knot": {
"type": "string",
"description": "knot where the repo was created"
···
"format": "uri",
"description": "source of the repo"
},
"createdAt": {
"type": "string",
"format": "datetime"
···
"required": [
"name",
"knot",
"createdAt"
],
"properties": {
···
"type": "string",
"description": "name of the repo"
},
"knot": {
"type": "string",
"description": "knot where the repo was created"
···
"format": "uri",
"description": "source of the repo"
},
+
"labels": {
+
"type": "array",
+
"description": "List of labels that this repo subscribes to",
+
"items": {
+
"type": "string",
+
"format": "at-uri"
+
}
+
},
"createdAt": {
"type": "string",
"format": "datetime"
+1 -1
spindle/xrpc/add_secret.go
···
}
repo := resp.Value.Val.(*tangled.Repo)
-
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
if err != nil {
fail(xrpcerr.GenericError(err))
return
···
}
repo := resp.Value.Val.(*tangled.Repo)
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
if err != nil {
fail(xrpcerr.GenericError(err))
return
+1 -1
spindle/xrpc/list_secrets.go
···
}
repo := resp.Value.Val.(*tangled.Repo)
-
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
if err != nil {
fail(xrpcerr.GenericError(err))
return
···
}
repo := resp.Value.Val.(*tangled.Repo)
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
if err != nil {
fail(xrpcerr.GenericError(err))
return
+1 -1
spindle/xrpc/remove_secret.go
···
}
repo := resp.Value.Val.(*tangled.Repo)
-
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
if err != nil {
fail(xrpcerr.GenericError(err))
return
···
}
repo := resp.Value.Val.(*tangled.Repo)
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
if err != nil {
fail(xrpcerr.GenericError(err))
return
+6
appview/pages/templates/repo/fragments/colorBall.html
···
···
+
{{ define "repo/fragments/colorBall" }}
+
<div
+
class="size-2 rounded-full {{ .classes }}"
+
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ .color }} 70%, white), {{ .color }} 30%, color-mix(in srgb, {{ .color }} 85%, black));"
+
></div>
+
{{ end }}
+1 -1
appview/pages/templates/user/overview.html
···
{{ with .Repo.RepoStats }}
{{ with .Language }}
<div class="flex gap-2 items-center text-xs font-mono text-gray-400 ">
-
{{ template "repo/fragments/languageBall" . }}
<span>{{ . }}</span>
</div>
{{end }}
···
{{ with .Repo.RepoStats }}
{{ with .Language }}
<div class="flex gap-2 items-center text-xs font-mono text-gray-400 ">
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
<span>{{ . }}</span>
</div>
{{end }}
+1 -1
appview/issues/router.go
···
r.With(middleware.Paginate).Get("/", i.RepoIssues)
r.Route("/{issue}", func(r chi.Router) {
-
r.Use(mw.ResolveIssue())
r.Get("/", i.RepoSingleIssue)
// authenticated routes
···
r.With(middleware.Paginate).Get("/", i.RepoIssues)
r.Route("/{issue}", func(r chi.Router) {
+
r.Use(mw.ResolveIssue)
r.Get("/", i.RepoSingleIssue)
// authenticated routes
+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"
+6
appview/pages/templates/labels/fragments/labelDef.html
···
···
+
{{ define "labels/fragments/labelDef" }}
+
<span class="flex items-center gap-2 font-normal normal-case">
+
{{ template "repo/fragments/colorBall" (dict "color" .GetColor) }}
+
{{ .Name }}
+
</span>
+
{{ end }}
+6 -8
appview/pages/templates/layouts/repobase.html
···
{{ template "repo/fragments/repoDescription" . }}
</section>
-
<section
-
class="w-full flex flex-col"
-
>
<nav class="w-full pl-4 overflow-auto">
<div class="flex z-60">
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
···
{{ end }}
</div>
</nav>
-
<section
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"
-
>
{{ block "repoContent" . }}{{ end }}
-
</section>
-
{{ block "repoAfter" . }}{{ end }}
</section>
{{ end }}
···
{{ template "repo/fragments/repoDescription" . }}
</section>
+
<section class="w-full flex flex-col" >
<nav class="w-full pl-4 overflow-auto">
<div class="flex z-60">
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
···
{{ end }}
</div>
</nav>
+
{{ block "repoContentLayout" . }}
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
{{ block "repoContent" . }}{{ end }}
+
</section>
+
{{ block "repoAfter" . }}{{ end }}
+
{{ end }}
</section>
{{ end }}
+4 -4
appview/pages/templates/repo/issues/fragments/commentList.html
···
{{ range $item := .CommentList }}
{{ template "commentListing" (list $ .) }}
{{ end }}
-
<div>
{{ end }}
{{ define "commentListing" }}
···
"Issue" $root.Issue
"Comment" $comment.Self) }}
-
<div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm">
{{ template "topLevelComment" $params }}
-
<div class="relative ml-4 border-l border-gray-300 dark:border-gray-700">
{{ range $index, $reply := $comment.Replies }}
<div class="relative ">
<!-- Horizontal connector -->
-
<div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div>
<div class="pl-2">
{{
···
{{ range $item := .CommentList }}
{{ template "commentListing" (list $ .) }}
{{ end }}
+
</div>
{{ end }}
{{ define "commentListing" }}
···
"Issue" $root.Issue
"Comment" $comment.Self) }}
+
<div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50">
{{ template "topLevelComment" $params }}
+
<div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700">
{{ range $index, $reply := $comment.Replies }}
<div class="relative ">
<!-- Horizontal connector -->
+
<div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div>
<div class="pl-2">
{{
+21 -2
appview/pages/templates/labels/fragments/label.html
···
{{ define "labels/fragments/label" }}
{{ $d := .def }}
{{ $v := .val }}
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
-
{{ $d.Name }}{{ if not $d.ValueType.IsNull }}/{{ template "labelVal" (dict "def" $d "val" $v) }}{{ end }}
</span>
{{ end }}
···
{{ $v := .val }}
{{ if $d.ValueType.IsDidFormat }}
-
{{ resolve $v }}
{{ else }}
{{ $v }}
{{ end }}
···
{{ define "labels/fragments/label" }}
{{ $d := .def }}
{{ $v := .val }}
+
{{ $withPrefix := .withPrefix }}
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
+
+
{{ $lhs := printf "%s" $d.Name }}
+
{{ $rhs := "" }}
+
+
{{ if not $d.ValueType.IsNull }}
+
{{ if $d.ValueType.IsDidFormat }}
+
{{ $v = resolve $v }}
+
{{ end }}
+
+
{{ if not $withPrefix }}
+
{{ $lhs = "" }}
+
{{ else }}
+
{{ $lhs = printf "%s/" $d.Name }}
+
{{ end }}
+
+
{{ $rhs = printf "%s" $v }}
+
{{ end }}
+
+
{{ printf "%s%s" $lhs $rhs }}
</span>
{{ end }}
···
{{ $v := .val }}
{{ if $d.ValueType.IsDidFormat }}
+
{{ resolve $v }}
{{ else }}
{{ $v }}
{{ end }}
-127
appview/pages/templates/repo/fragments/addLabelModal.html
···
-
{{ define "repo/fragments/addLabelModal" }}
-
{{ $root := .root }}
-
{{ $subject := .subject }}
-
{{ $state := .state }}
-
{{ with $root }}
-
<form
-
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
-
hx-on::after-request="this.reset()"
-
hx-indicator="#spinner"
-
hx-swap="none"
-
class="flex flex-col gap-4"
-
>
-
<p class="text-gray-500 dark:text-gray-400">Add, remove or update labels.</p>
-
-
<input class="hidden" name="repo" value="{{ .RepoInfo.RepoAt.String }}">
-
<input class="hidden" name="subject" value="{{ $subject }}">
-
-
<div class="flex flex-col gap-2">
-
{{ $id := 0 }}
-
{{ range $k, $valset := $state.Inner }}
-
{{ $d := index $root.LabelDefs $k }}
-
{{ range $v, $s := $valset }}
-
{{ template "labelCheckbox" (dict "def" $d "key" $k "val" $v "id" $id "isChecked" true) }}
-
{{ $id = add $id 1 }}
-
{{ end }}
-
{{ end }}
-
-
{{ range $k, $d := $root.LabelDefs }}
-
{{ if not ($state.ContainsLabel $k) }}
-
{{ template "labelCheckbox" (dict "def" $d "key" $k "val" "" "id" $id "isChecked" false) }}
-
{{ $id = add $id 1 }}
-
{{ end }}
-
{{ else }}
-
<span>
-
No labels defined yet. You can define custom labels in <a class="underline" href="/{{ .RepoInfo.FullName }}/settings">settings</a>.
-
</span>
-
{{ end }}
-
</div>
-
-
<div class="flex gap-2 pt-2">
-
<button
-
type="button"
-
popovertarget="add-label-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 "check" "size-4" }} save</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>
-
{{ end }}
-
{{ end }}
-
-
{{ define "labelCheckbox" }}
-
{{ $key := .key }}
-
{{ $val := .val }}
-
{{ $def := .def }}
-
{{ $id := .id }}
-
{{ $isChecked := .isChecked }}
-
<div class="grid grid-cols-[auto_1fr_50%] gap-2 items-center cursor-pointer">
-
<input type="checkbox" id="op-{{$id}}" name="op-{{$id}}" value="add" {{if $isChecked}}checked{{end}} class="peer">
-
<label for="op-{{$id}}" class="flex items-center gap-2 text-base">{{ template "labels/fragments/labelDef" $def }}</label>
-
<div class="w-full hidden peer-checked:block">{{ template "valueTypeInput" (dict "valueType" $def.ValueType "value" $val "key" $key) }}</div>
-
<input type="hidden" name="operand-key" value="{{ $key }}">
-
</div>
-
{{ end }}
-
-
{{ define "valueTypeInput" }}
-
{{ $valueType := .valueType }}
-
{{ $value := .value }}
-
{{ $key := .key }}
-
-
{{ if $valueType.IsEnumType }}
-
{{ template "enumTypeInput" $ }}
-
{{ else if $valueType.IsBool }}
-
{{ template "boolTypeInput" $ }}
-
{{ else if $valueType.IsInt }}
-
{{ template "intTypeInput" $ }}
-
{{ else if $valueType.IsString }}
-
{{ template "stringTypeInput" $ }}
-
{{ else if $valueType.IsNull }}
-
{{ template "nullTypeInput" $ }}
-
{{ end }}
-
{{ end }}
-
-
{{ define "enumTypeInput" }}
-
{{ $valueType := .valueType }}
-
{{ $value := .value }}
-
<select name="operand-val" class="w-full p-1 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
-
{{ range $valueType.Enum }}
-
<option value="{{.}}" {{ if eq $value . }} selected {{ end }}>{{.}}</option>
-
{{ end }}
-
</select>
-
{{ end }}
-
-
{{ define "boolTypeInput" }}
-
{{ $value := .value }}
-
<select name="operand-val" class="w-full p-1 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
-
<option value="true" {{ if $value }} selected {{ end }}>true</option>
-
<option value="false" {{ if not $value }} selected {{ end }}>false</option>
-
</select>
-
{{ end }}
-
-
{{ define "intTypeInput" }}
-
{{ $value := .value }}
-
<input class="p-1 w-full" type="number" name="operand-val" value="{{$value}}" max="100">
-
{{ end }}
-
-
{{ define "stringTypeInput" }}
-
{{ $valueType := .valueType }}
-
{{ $value := .value }}
-
{{ if $valueType.IsDidFormat }}
-
{{ $value = resolve .value }}
-
{{ end }}
-
<input class="p-1 w-full" type="text" name="operand-val" value="{{$value}}">
-
{{ end }}
-
-
{{ define "nullTypeInput" }}
-
<input class="p-1" type="hidden" name="operand-val" value="null">
-
{{ end }}
···
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
···
···
+
{{ define "repo/fragments/editLabelPanel" }}
+
<form
+
id="edit-label-panel"
+
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
+
hx-indicator="#spinner"
+
hx-disabled-elt="#save-btn,#cancel-btn"
+
hx-swap="none"
+
class="flex flex-col gap-6"
+
>
+
<input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}">
+
<input type="hidden" name="subject" value="{{ .Subject }}">
+
{{ template "editBasicLabels" . }}
+
{{ template "editKvLabels" . }}
+
{{ template "editLabelPanelActions" . }}
+
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
+
</form>
+
{{ end }}
+
+
{{ define "editBasicLabels" }}
+
{{ $defs := .Defs }}
+
{{ $subject := .Subject }}
+
{{ $state := .State }}
+
{{ $labelStyle := "flex items-center gap-2 rounded py-1 px-2 border border-gray-200 dark:border-gray-700 text-sm bg-white dark:bg-gray-800 text-black dark:text-white" }}
+
<div>
+
{{ template "repo/fragments/labelSectionHeaderText" "Labels" }}
+
+
<div class="flex gap-1 items-center flex-wrap">
+
{{ range $k, $d := $defs }}
+
{{ $isChecked := $state.ContainsLabel $k }}
+
{{ if $d.ValueType.IsNull }}
+
{{ $fieldName := $d.AtUri }}
+
<label class="{{$labelStyle}}">
+
<input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}>
+
{{ template "labels/fragments/labelDef" $d }}
+
</label>
+
{{ end }}
+
{{ else }}
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">
+
No labels defined yet. You can choose default labels or define custom
+
labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>.
+
</p>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "editKvLabels" }}
+
{{ $defs := .Defs }}
+
{{ $subject := .Subject }}
+
{{ $state := .State }}
+
{{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-0" }}
+
+
{{ range $k, $d := $defs }}
+
{{ if (not $d.ValueType.IsNull) }}
+
{{ $fieldName := $d.AtUri }}
+
{{ $valset := $state.GetValSet $k }}
+
<div id="label-{{$d.Id}}" class="flex flex-col gap-1">
+
{{ template "repo/fragments/labelSectionHeaderText" $d.Name }}
+
{{ if (and $d.Multiple $d.ValueType.IsEnum) }}
+
<!-- checkbox -->
+
{{ range $variant := $d.ValueType.Enum }}
+
<label class="{{$labelStyle}}">
+
<input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
+
{{ $variant }}
+
</label>
+
{{ end }}
+
{{ else if $d.Multiple }}
+
<!-- dynamically growing input fields -->
+
{{ range $v, $s := $valset }}
+
{{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }}
+
{{ else }}
+
{{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }}
+
{{ end }}
+
{{ template "addFieldButton" $d }}
+
{{ else if $d.ValueType.IsEnum }}
+
<!-- radio buttons -->
+
{{ $isUsed := $state.ContainsLabel $k }}
+
{{ range $variant := $d.ValueType.Enum }}
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
+
{{ $variant }}
+
</label>
+
{{ end }}
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}>
+
None
+
</label>
+
{{ else }}
+
<!-- single input field based on value type -->
+
{{ range $v, $s := $valset }}
+
{{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }}
+
{{ else }}
+
{{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }}
+
{{ end }}
+
{{ end }}
+
</div>
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "multipleInputField" }}
+
<div class="flex gap-1 items-stretch">
+
{{ template "valueTypeInput" . }}
+
{{ template "removeFieldButton" }}
+
</div>
+
{{ end }}
+
+
{{ define "addFieldButton" }}
+
<div style="display:none" id="tpl-{{ .Id }}">
+
{{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }}
+
</div>
+
<button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2">
+
{{ i "plus" "size-4" }} add
+
</button>
+
{{ end }}
+
+
{{ define "removeFieldButton" }}
+
<button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500">
+
{{ i "trash-2" "size-4" }}
+
</button>
+
{{ end }}
+
+
{{ define "valueTypeInput" }}
+
{{ $def := .def }}
+
{{ $valueType := $def.ValueType }}
+
{{ $value := .value }}
+
{{ $key := .key }}
+
+
{{ if $valueType.IsBool }}
+
{{ template "boolTypeInput" $ }}
+
{{ else if $valueType.IsInt }}
+
{{ template "intTypeInput" $ }}
+
{{ else if $valueType.IsString }}
+
{{ template "stringTypeInput" $ }}
+
{{ else if $valueType.IsNull }}
+
{{ template "nullTypeInput" $ }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "boolTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
{{ $value := .value }}
+
{{ $labelStyle = "font-normal normal-case flex items-center gap-2" }}
+
<div class="flex flex-col gap-1">
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
+
None
+
</label>
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
+
None
+
</label>
+
<label class="{{$labelStyle}}">
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
+
None
+
</label>
+
</div>
+
{{ end }}
+
+
{{ define "intTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
{{ $value := .value }}
+
<input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}">
+
{{ end }}
+
+
{{ define "stringTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
{{ $valueType := $def.ValueType }}
+
{{ $value := .value }}
+
{{ if $valueType.IsDidFormat }}
+
{{ $value = trimPrefix (resolve .value) "@" }}
+
{{ end }}
+
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
+
{{ end }}
+
+
{{ define "nullTypeInput" }}
+
{{ $def := .def }}
+
{{ $fieldName := $def.AtUri }}
+
<input class="p-1" type="hidden" name="{{$fieldName}}" value="null">
+
{{ end }}
+
+
{{ define "editLabelPanelActions" }}
+
<div class="flex gap-2 pt-2">
+
<button
+
id="cancel-btn"
+
type="button"
+
hx-get="/{{ .RepoInfo.FullName }}/label"
+
hx-vals='{"subject": "{{.Subject}}"}'
+
hx-swap="outerHTML"
+
hx-target="#edit-label-panel"
+
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 group">
+
{{ i "x" "size-4" }} cancel
+
</button>
+
+
<button
+
id="save-btn"
+
type="submit"
+
class="btn w-1/2 flex items-center">
+
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</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>
+
{{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
···
···
+
{{ define "repo/fragments/labelSectionHeader" }}
+
+
<div class="flex justify-between items-center gap-2">
+
{{ template "repo/fragments/labelSectionHeaderText" .Name }}
+
{{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/label/edit"
+
hx-vals='{"subject": "{{.Subject}}"}'
+
hx-swap="outerHTML"
+
hx-target="#label-panel">
+
{{ i "pencil" "size-3" }}
+
</a>
+
{{ end }}
+
</div>
+
{{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
···
···
+
{{ define "repo/fragments/labelSectionHeaderText" }}
+
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span>
+
{{ end }}
+138 -86
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"
-
hx-on::after-request="if(event.detail.successful) this.reset()"
-
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="null" selected>None</option>
-
<option value="string">String</option>
-
<option value="integer">Integer</option>
-
<option value="boolean">Boolean</option>
-
</select>
-
<details id="constrain-values" class="group hidden">
-
<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>
-
<label for="enumValues">Permitted values</label>
-
<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>
-
-
<label for="valueFormat">String format</label>
-
<select id="valueFormat" name="valueFormat" 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="any" selected>Any</option>
-
<option value="did">DID</option>
-
</select>
-
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Choose a string format.</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">
···
{{ 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') {
-
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 }}
···
{{ define "repo/settings/fragments/addLabelDefModal" }}
+
<div class="grid grid-cols-2">
+
<input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked>
+
<input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv">
+
<!-- Labels as direct siblings -->
+
{{ $base := "py-2 text-sm font-normal normal-case block hover:no-underline text-center cursor-pointer bg-gray-100 dark:bg-gray-800 shadow-inner border border-gray-200 dark:border-gray-700" }}
+
<label for="basic-tab" class="{{$base}} peer-checked/basic:bg-white peer-checked/basic:dark:bg-gray-700 peer-checked/basic:shadow-sm rounded-l">
+
Basic Labels
+
</label>
+
<label for="kv-tab" class="{{$base}} peer-checked/kv:bg-white peer-checked/kv:dark:bg-gray-700 peer-checked/kv:shadow-sm rounded-r">
+
Key-value Labels
+
</label>
+
+
<!-- Basic Labels Content - direct sibling -->
+
<div class="mt-4 hidden peer-checked/basic:block col-span-full">
+
{{ template "basicLabelDef" . }}
</div>
+
<!-- Key-value Labels Content - direct sibling -->
+
<div class="mt-4 hidden peer-checked/kv:block col-span-full">
+
{{ template "kvLabelDef" . }}
</div>
+
<div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div>
+
</div>
+
{{ end }}
+
+
{{ define "basicLabelDef" }}
+
<form
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
class="flex flex-col space-y-4">
+
+
<p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p>
+
+
{{ template "nameInput" . }}
+
{{ template "scopeInput" . }}
+
{{ template "colorInput" . }}
+
+
<div class="flex gap-2 pt-2">
+
{{ template "cancelButton" . }}
+
{{ template "submitButton" . }}
+
</div>
+
</form>
+
{{ end }}
+
+
{{ define "kvLabelDef" }}
+
<form
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
class="flex flex-col space-y-4">
+
+
<p class="text-gray-500 dark:text-gray-400">
+
These labels are more detailed, they can have a key and an associated
+
value. You may define additional constraints on label values.
+
</p>
+
+
{{ template "nameInput" . }}
+
{{ template "valueInput" . }}
+
{{ template "multipleInput" . }}
+
{{ template "scopeInput" . }}
+
{{ template "colorInput" . }}
+
+
<div class="flex gap-2 pt-2">
+
{{ template "cancelButton" . }}
+
{{ template "submitButton" . }}
+
</div>
+
</form>
+
{{ end }}
+
+
{{ define "nameInput" }}
<div class="w-full">
+
<label for="name">Name</label>
+
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
</div>
+
{{ end }}
+
{{ define "colorInput" }}
<div class="w-full">
<label for="color">Color</label>
<div class="grid grid-cols-4 grid-rows-2 place-items-center">
···
{{ end }}
</div>
</div>
+
{{ end }}
+
{{ define "scopeInput" }}
+
<div class="w-full">
+
<label>Scope</label>
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
+
<input type="checkbox" id="issue-scope" name="scope" value="sh.tangled.repo.issue" checked />
+
Issues
+
</label>
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
+
<input type="checkbox" id="pulls-scope" name="scope" value="sh.tangled.repo.pull" checked />
+
Pull Requests
+
</label>
+
</div>
+
{{ end }}
+
+
{{ define "valueInput" }}
+
<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">String</option>
+
<option value="integer">Integer</option>
+
</select>
</div>
+
<div class="w-full">
+
<label for="enumValues">Permitted values</label>
+
<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, or leave empty to allow any value.
+
</p>
</div>
+
+
<div class="w-full">
+
<label for="valueFormat">String format</label>
+
<select id="valueFormat" name="valueFormat" 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="any" selected>Any</option>
+
<option value="did">DID</option>
+
</select>
+
</div>
+
{{ end }}
+
+
{{ define "multipleInput" }}
+
<div class="w-full flex flex-wrap gap-2">
+
<input type="checkbox" id="multiple" name="multiple" value="true" />
+
<span>Allow multiple values</span>
+
</div>
{{ end }}
+
{{ define "cancelButton" }}
+
<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>
+
{{ end }}
+
+
{{ define "submitButton" }}
+
<button type="submit" class="btn-create 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>
+
{{ end }}
+
+
+1
appview/oauth/handler/handler.go
···
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/oauth/client"
"tangled.org/core/appview/pages"
"tangled.org/core/idresolver"
"tangled.org/core/rbac"
"tangled.org/core/tid"
···
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/oauth/client"
"tangled.org/core/appview/pages"
+
"tangled.org/core/consts"
"tangled.org/core/idresolver"
"tangled.org/core/rbac"
"tangled.org/core/tid"
+25 -27
appview/pages/templates/repo/settings/fragments/labelListing.html
···
{{ define "repo/settings/fragments/labelListing" }}
{{ $root := index . 0 }}
{{ $label := index . 1 }}
-
<div id="label-{{$label.Id}}" class="flex items-center justify-between p-2 pl-4">
-
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
-
{{ template "labels/fragments/labelDef" $label }}
-
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
{{ $label.ValueType.Type }} type
-
{{ if $label.ValueType.IsEnumType }}
-
<span class="before:content-['ยท'] before:select-none"></span>
-
{{ join $label.ValueType.Enum ", " }}
-
{{ end }}
-
{{ if $label.ValueType.IsDidFormat }}
-
<span class="before:content-['ยท'] before:select-none"></span>
-
DID format
-
{{ end }}
-
</div>
</div>
-
{{ if $root.RepoInfo.Roles.IsOwner }}
-
<button
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
-
title="Delete label"
-
hx-delete="/{{ $root.RepoInfo.FullName }}/settings/label"
-
hx-swap="none"
-
hx-vals='{"label-id": "{{ $label.Id }}"}'
-
hx-confirm="Are you sure you want to delete the label `{{ $label.Name }}`?"
-
>
-
{{ i "trash-2" "w-5 h-5" }}
-
<span class="hidden md:inline">delete</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
</div>
{{ end }}
···
{{ define "repo/settings/fragments/labelListing" }}
{{ $root := index . 0 }}
{{ $label := index . 1 }}
+
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
+
{{ template "labels/fragments/labelDef" $label }}
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
+
{{ if $label.ValueType.IsNull }}
+
basic
+
{{ else }}
{{ $label.ValueType.Type }} type
+
{{ end }}
+
+
{{ if $label.ValueType.IsEnum }}
+
<span class="before:content-['ยท'] before:select-none"></span>
+
{{ join $label.ValueType.Enum ", " }}
+
{{ end }}
+
+
{{ if $label.ValueType.IsDidFormat }}
+
<span class="before:content-['ยท'] before:select-none"></span>
+
DID format
+
{{ end }}
+
+
{{ if $label.Multiple }}
+
<span class="before:content-['ยท'] before:select-none"></span>
+
multiple
+
{{ end }}
+
+
<span class="before:content-['ยท'] before:select-none"></span>
+
{{ join $label.Scope ", " }}
</div>
</div>
{{ end }}
+9
consts/consts.go
···
···
+
package consts
+
+
const (
+
TangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
+
IcyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq"
+
+
DefaultSpindle = "spindle.tangled.sh"
+
DefaultKnot = "knot1.tangled.sh"
+
)
+30
appview/models/artifact.go
···
···
+
package models
+
+
import (
+
"fmt"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/go-git/go-git/v5/plumbing"
+
"github.com/ipfs/go-cid"
+
"tangled.org/core/api/tangled"
+
)
+
+
type Artifact struct {
+
Id uint64
+
Did string
+
Rkey string
+
+
RepoAt syntax.ATURI
+
Tag plumbing.Hash
+
CreatedAt time.Time
+
+
BlobCid cid.Cid
+
Name string
+
Size uint64
+
MimeType string
+
}
+
+
func (a *Artifact) ArtifactAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
+
}
+21
appview/models/collaborator.go
···
···
+
package models
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type Collaborator struct {
+
// identifiers for the record
+
Id int64
+
Did syntax.DID
+
Rkey string
+
+
// content
+
SubjectDid syntax.DID
+
RepoAt syntax.ATURI
+
+
// meta
+
Created time.Time
+
}
+16
appview/models/email.go
···
···
+
package models
+
+
import (
+
"time"
+
)
+
+
type Email struct {
+
ID int64
+
Did string
+
Address string
+
Verified bool
+
Primary bool
+
VerificationCode string
+
LastSent *time.Time
+
CreatedAt time.Time
+
}
+26 -57
appview/db/follow.go
···
"log"
"strings"
"time"
-
)
-
type Follow struct {
-
UserDid string
-
SubjectDid string
-
FollowedAt time.Time
-
Rkey string
-
}
-
func AddFollow(e Execer, follow *Follow) error {
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
return err
}
// Get a follow record
-
func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) {
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
row := e.QueryRow(query, userDid, subjectDid)
-
var follow Follow
var followedAt string
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
if err != nil {
···
return err
}
-
type FollowStats struct {
-
Followers int64
-
Following int64
-
}
-
-
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
var followers, following int64
err := e.QueryRow(
`SELECT
···
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
FROM follows;`, did, did).Scan(&followers, &following)
if err != nil {
-
return FollowStats{}, err
}
-
return FollowStats{
Followers: followers,
Following: following,
}, nil
}
-
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
if len(dids) == 0 {
return nil, nil
}
···
) g on f.did = g.did`,
placeholderStr, placeholderStr)
-
result := make(map[string]FollowStats)
rows, err := e.Query(query, args...)
if err != nil {
···
if err := rows.Scan(&did, &followers, &following); err != nil {
return nil, err
}
-
result[did] = FollowStats{
Followers: followers,
Following: following,
}
···
for _, did := range dids {
if _, exists := result[did]; !exists {
-
result[did] = FollowStats{
Followers: 0,
Following: 0,
}
···
return result, nil
}
-
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
-
var follows []Follow
var conditions []string
var args []any
···
return nil, err
}
for rows.Next() {
-
var follow Follow
var followedAt string
err := rows.Scan(
&follow.UserDid,
···
return follows, nil
}
-
func GetFollowers(e Execer, did string) ([]Follow, error) {
return GetFollows(e, 0, FilterEq("subject_did", did))
}
-
func GetFollowing(e Execer, did string) ([]Follow, error) {
return GetFollows(e, 0, FilterEq("user_did", did))
}
-
type FollowStatus int
-
-
const (
-
IsNotFollowing FollowStatus = iota
-
IsFollowing
-
IsSelf
-
)
-
-
func (s FollowStatus) String() string {
-
switch s {
-
case IsNotFollowing:
-
return "IsNotFollowing"
-
case IsFollowing:
-
return "IsFollowing"
-
case IsSelf:
-
return "IsSelf"
-
default:
-
return "IsNotFollowing"
-
}
-
}
-
-
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
if len(subjectDids) == 0 || userDid == "" {
-
return make(map[string]FollowStatus), nil
}
-
result := make(map[string]FollowStatus)
for _, subjectDid := range subjectDids {
if userDid == subjectDid {
-
result[subjectDid] = IsSelf
} else {
-
result[subjectDid] = IsNotFollowing
}
}
···
if err := rows.Scan(&subjectDid); err != nil {
return nil, err
}
-
result[subjectDid] = IsFollowing
}
return result, nil
}
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
if err != nil {
-
return IsNotFollowing
}
return statuses[subjectDid]
}
-
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
return getFollowStatuses(e, userDid, subjectDids)
}
···
"log"
"strings"
"time"
+
"tangled.org/core/appview/models"
+
)
+
func AddFollow(e Execer, follow *models.Follow) error {
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
return err
}
// Get a follow record
+
func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) {
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
row := e.QueryRow(query, userDid, subjectDid)
+
var follow models.Follow
var followedAt string
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
if err != nil {
···
return err
}
+
func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) {
var followers, following int64
err := e.QueryRow(
`SELECT
···
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
FROM follows;`, did, did).Scan(&followers, &following)
if err != nil {
+
return models.FollowStats{}, err
}
+
return models.FollowStats{
Followers: followers,
Following: following,
}, nil
}
+
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) {
if len(dids) == 0 {
return nil, nil
}
···
) g on f.did = g.did`,
placeholderStr, placeholderStr)
+
result := make(map[string]models.FollowStats)
rows, err := e.Query(query, args...)
if err != nil {
···
if err := rows.Scan(&did, &followers, &following); err != nil {
return nil, err
}
+
result[did] = models.FollowStats{
Followers: followers,
Following: following,
}
···
for _, did := range dids {
if _, exists := result[did]; !exists {
+
result[did] = models.FollowStats{
Followers: 0,
Following: 0,
}
···
return result, nil
}
+
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
+
var follows []models.Follow
var conditions []string
var args []any
···
return nil, err
}
for rows.Next() {
+
var follow models.Follow
var followedAt string
err := rows.Scan(
&follow.UserDid,
···
return follows, nil
}
+
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
return GetFollows(e, 0, FilterEq("subject_did", did))
}
+
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
return GetFollows(e, 0, FilterEq("user_did", did))
}
+
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
if len(subjectDids) == 0 || userDid == "" {
+
return make(map[string]models.FollowStatus), nil
}
+
result := make(map[string]models.FollowStatus)
for _, subjectDid := range subjectDids {
if userDid == subjectDid {
+
result[subjectDid] = models.IsSelf
} else {
+
result[subjectDid] = models.IsNotFollowing
}
}
···
if err := rows.Scan(&subjectDid); err != nil {
return nil, err
}
+
result[subjectDid] = models.IsFollowing
}
return result, nil
}
+
func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus {
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
if err != nil {
+
return models.IsNotFollowing
}
return statuses[subjectDid]
}
+
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
return getFollowStatuses(e, userDid, subjectDids)
}
+38
appview/models/follow.go
···
···
+
package models
+
+
import (
+
"time"
+
)
+
+
type Follow struct {
+
UserDid string
+
SubjectDid string
+
FollowedAt time.Time
+
Rkey string
+
}
+
+
type FollowStats struct {
+
Followers int64
+
Following int64
+
}
+
+
type FollowStatus int
+
+
const (
+
IsNotFollowing FollowStatus = iota
+
IsFollowing
+
IsSelf
+
)
+
+
func (s FollowStatus) String() string {
+
switch s {
+
case IsNotFollowing:
+
return "IsNotFollowing"
+
case IsFollowing:
+
return "IsFollowing"
+
case IsSelf:
+
return "IsSelf"
+
default:
+
return "IsNotFollowing"
+
}
+
}
+2 -1
appview/knots/knots.go
···
"tangled.org/core/appview/config"
"tangled.org/core/appview/db"
"tangled.org/core/appview/middleware"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/serververify"
···
}
// organize repos by did
-
repoMap := make(map[string][]db.Repo)
for _, r := range repos {
repoMap[r.Did] = append(repoMap[r.Did], r)
}
···
"tangled.org/core/appview/config"
"tangled.org/core/appview/db"
"tangled.org/core/appview/middleware"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/serververify"
···
}
// organize repos by did
+
repoMap := make(map[string][]models.Repo)
for _, r := range repos {
repoMap[r.Did] = append(repoMap[r.Did], r)
}
+3 -3
appview/pages/repoinfo/repoinfo.go
···
"strings"
"github.com/bluesky-social/indigo/atproto/syntax"
-
"tangled.org/core/appview/db"
"tangled.org/core/appview/state/userutil"
)
···
Spindle string
RepoAt syntax.ATURI
IsStarred bool
-
Stats db.RepoStats
Roles RolesInRepo
-
Source *db.Repo
SourceHandle string
Ref string
DisableFork bool
···
"strings"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/state/userutil"
)
···
Spindle string
RepoAt syntax.ATURI
IsStarred bool
+
Stats models.RepoStats
Roles RolesInRepo
+
Source *models.Repo
SourceHandle string
Ref string
DisableFork bool
+15 -200
appview/db/issues.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
-
"tangled.org/core/api/tangled"
"tangled.org/core/appview/models"
"tangled.org/core/appview/pagination"
)
-
type Issue struct {
-
Id int64
-
Did string
-
Rkey string
-
RepoAt syntax.ATURI
-
IssueId int
-
Created time.Time
-
Edited *time.Time
-
Deleted *time.Time
-
Title string
-
Body string
-
Open bool
-
-
// optionally, populate this when querying for reverse mappings
-
// like comment counts, parent repo etc.
-
Comments []IssueComment
-
Labels models.LabelState
-
Repo *models.Repo
-
}
-
-
func (i *Issue) AtUri() syntax.ATURI {
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
-
}
-
-
func (i *Issue) AsRecord() tangled.RepoIssue {
-
return tangled.RepoIssue{
-
Repo: i.RepoAt.String(),
-
Title: i.Title,
-
Body: &i.Body,
-
CreatedAt: i.Created.Format(time.RFC3339),
-
}
-
}
-
-
func (i *Issue) State() string {
-
if i.Open {
-
return "open"
-
}
-
return "closed"
-
}
-
-
type CommentListItem struct {
-
Self *IssueComment
-
Replies []*IssueComment
-
}
-
-
func (i *Issue) CommentList() []CommentListItem {
-
// Create a map to quickly find comments by their aturi
-
toplevel := make(map[string]*CommentListItem)
-
var replies []*IssueComment
-
-
// collect top level comments into the map
-
for _, comment := range i.Comments {
-
if comment.IsTopLevel() {
-
toplevel[comment.AtUri().String()] = &CommentListItem{
-
Self: &comment,
-
}
-
} else {
-
replies = append(replies, &comment)
-
}
-
}
-
-
for _, r := range replies {
-
parentAt := *r.ReplyTo
-
if parent, exists := toplevel[parentAt]; exists {
-
parent.Replies = append(parent.Replies, r)
-
}
-
}
-
-
var listing []CommentListItem
-
for _, v := range toplevel {
-
listing = append(listing, *v)
-
}
-
-
// sort everything
-
sortFunc := func(a, b *IssueComment) bool {
-
return a.Created.Before(b.Created)
-
}
-
sort.Slice(listing, func(i, j int) bool {
-
return sortFunc(listing[i].Self, listing[j].Self)
-
})
-
for _, r := range listing {
-
sort.Slice(r.Replies, func(i, j int) bool {
-
return sortFunc(r.Replies[i], r.Replies[j])
-
})
-
}
-
-
return listing
-
}
-
-
func (i *Issue) Participants() []string {
-
participantSet := make(map[string]struct{})
-
participants := []string{}
-
-
addParticipant := func(did string) {
-
if _, exists := participantSet[did]; !exists {
-
participantSet[did] = struct{}{}
-
participants = append(participants, did)
-
}
-
}
-
-
addParticipant(i.Did)
-
-
for _, c := range i.Comments {
-
addParticipant(c.Did)
-
}
-
-
return participants
-
}
-
-
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
-
if err != nil {
-
created = time.Now()
-
}
-
-
body := ""
-
if record.Body != nil {
-
body = *record.Body
-
}
-
-
return Issue{
-
RepoAt: syntax.ATURI(record.Repo),
-
Did: did,
-
Rkey: rkey,
-
Created: created,
-
Title: record.Title,
-
Body: body,
-
Open: true, // new issues are open by default
-
}
-
}
-
-
type IssueComment struct {
-
Id int64
-
Did string
-
Rkey string
-
IssueAt string
-
ReplyTo *string
-
Body string
-
Created time.Time
-
Edited *time.Time
-
Deleted *time.Time
-
}
-
-
func (i *IssueComment) AtUri() syntax.ATURI {
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
-
}
-
-
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
-
return tangled.RepoIssueComment{
-
Body: i.Body,
-
Issue: i.IssueAt,
-
CreatedAt: i.Created.Format(time.RFC3339),
-
ReplyTo: i.ReplyTo,
-
}
-
}
-
-
func (i *IssueComment) IsTopLevel() bool {
-
return i.ReplyTo == nil
-
}
-
-
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
-
if err != nil {
-
created = time.Now()
-
}
-
-
ownerDid := did
-
-
if _, err = syntax.ParseATURI(record.Issue); err != nil {
-
return nil, err
-
}
-
-
comment := IssueComment{
-
Did: ownerDid,
-
Rkey: rkey,
-
Body: record.Body,
-
IssueAt: record.Issue,
-
ReplyTo: record.ReplyTo,
-
Created: created,
-
}
-
-
return &comment, nil
-
}
-
-
func PutIssue(tx *sql.Tx, issue *Issue) error {
// ensure sequence exists
_, err := tx.Exec(`
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
}
}
-
func createNewIssue(tx *sql.Tx, issue *Issue) error {
// get next issue_id
var newIssueId int
err := tx.QueryRow(`
···
return row.Scan(&issue.Id, &issue.IssueId)
}
-
func updateIssue(tx *sql.Tx, issue *Issue) error {
// update existing issue
_, err := tx.Exec(`
update issues
···
return err
}
-
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
-
issueMap := make(map[string]*Issue) // at-uri -> issue
var conditions []string
var args []any
···
defer rows.Close()
for rows.Next() {
-
var issue Issue
var createdAt string
var editedAt, deletedAt sql.Null[string]
var rowNum int64
···
}
}
-
var issues []Issue
for _, i := range issueMap {
issues = append(issues, *i)
}
···
return issues, nil
}
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
}
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
row := e.QueryRow(query, repoAt, issueId)
-
var issue Issue
var createdAt string
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
if err != nil {
···
return &issue, nil
}
-
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
result, err := e.Exec(
`insert into issue_comments (
did,
···
return err
}
-
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
-
var comments []IssueComment
var conditions []string
var args []any
···
}
for rows.Next() {
-
var comment IssueComment
var created string
var rkey, edited, deleted, replyTo sql.Null[string]
err := rows.Scan(
···
var count models.IssueCount
if err := row.Scan(&count.Open, &count.Closed); err != nil {
-
return models.IssueCount{0, 0}, err
}
return count, nil
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
"tangled.org/core/appview/pagination"
)
+
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
// ensure sequence exists
_, err := tx.Exec(`
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
}
}
+
func createNewIssue(tx *sql.Tx, issue *models.Issue) error {
// get next issue_id
var newIssueId int
err := tx.QueryRow(`
···
return row.Scan(&issue.Id, &issue.IssueId)
}
+
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
// update existing issue
_, err := tx.Exec(`
update issues
···
return err
}
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
+
issueMap := make(map[string]*models.Issue) // at-uri -> issue
var conditions []string
var args []any
···
defer rows.Close()
for rows.Next() {
+
var issue models.Issue
var createdAt string
var editedAt, deletedAt sql.Null[string]
var rowNum int64
···
}
}
+
var issues []models.Issue
for _, i := range issueMap {
issues = append(issues, *i)
}
···
return issues, nil
}
+
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
}
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
row := e.QueryRow(query, repoAt, issueId)
+
var issue models.Issue
var createdAt string
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
if err != nil {
···
return &issue, nil
}
+
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
result, err := e.Exec(
`insert into issue_comments (
did,
···
return err
}
+
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
+
var comments []models.IssueComment
var conditions []string
var args []any
···
}
for rows.Next() {
+
var comment models.IssueComment
var created string
var rkey, edited, deleted, replyTo sql.Null[string]
err := rows.Scan(
···
var count models.IssueCount
if err := row.Scan(&count.Open, &count.Closed); err != nil {
+
return models.IssueCount{}, err
}
return count, nil
+194
appview/models/issue.go
···
···
+
package models
+
+
import (
+
"fmt"
+
"sort"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/api/tangled"
+
)
+
+
type Issue struct {
+
Id int64
+
Did string
+
Rkey string
+
RepoAt syntax.ATURI
+
IssueId int
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
+
Title string
+
Body string
+
Open bool
+
+
// optionally, populate this when querying for reverse mappings
+
// like comment counts, parent repo etc.
+
Comments []IssueComment
+
Labels LabelState
+
Repo *Repo
+
}
+
+
func (i *Issue) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
+
}
+
+
func (i *Issue) AsRecord() tangled.RepoIssue {
+
return tangled.RepoIssue{
+
Repo: i.RepoAt.String(),
+
Title: i.Title,
+
Body: &i.Body,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
}
+
}
+
+
func (i *Issue) State() string {
+
if i.Open {
+
return "open"
+
}
+
return "closed"
+
}
+
+
type CommentListItem struct {
+
Self *IssueComment
+
Replies []*IssueComment
+
}
+
+
func (i *Issue) CommentList() []CommentListItem {
+
// Create a map to quickly find comments by their aturi
+
toplevel := make(map[string]*CommentListItem)
+
var replies []*IssueComment
+
+
// collect top level comments into the map
+
for _, comment := range i.Comments {
+
if comment.IsTopLevel() {
+
toplevel[comment.AtUri().String()] = &CommentListItem{
+
Self: &comment,
+
}
+
} else {
+
replies = append(replies, &comment)
+
}
+
}
+
+
for _, r := range replies {
+
parentAt := *r.ReplyTo
+
if parent, exists := toplevel[parentAt]; exists {
+
parent.Replies = append(parent.Replies, r)
+
}
+
}
+
+
var listing []CommentListItem
+
for _, v := range toplevel {
+
listing = append(listing, *v)
+
}
+
+
// sort everything
+
sortFunc := func(a, b *IssueComment) bool {
+
return a.Created.Before(b.Created)
+
}
+
sort.Slice(listing, func(i, j int) bool {
+
return sortFunc(listing[i].Self, listing[j].Self)
+
})
+
for _, r := range listing {
+
sort.Slice(r.Replies, func(i, j int) bool {
+
return sortFunc(r.Replies[i], r.Replies[j])
+
})
+
}
+
+
return listing
+
}
+
+
func (i *Issue) Participants() []string {
+
participantSet := make(map[string]struct{})
+
participants := []string{}
+
+
addParticipant := func(did string) {
+
if _, exists := participantSet[did]; !exists {
+
participantSet[did] = struct{}{}
+
participants = append(participants, did)
+
}
+
}
+
+
addParticipant(i.Did)
+
+
for _, c := range i.Comments {
+
addParticipant(c.Did)
+
}
+
+
return participants
+
}
+
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
+
if err != nil {
+
created = time.Now()
+
}
+
+
body := ""
+
if record.Body != nil {
+
body = *record.Body
+
}
+
+
return Issue{
+
RepoAt: syntax.ATURI(record.Repo),
+
Did: did,
+
Rkey: rkey,
+
Created: created,
+
Title: record.Title,
+
Body: body,
+
Open: true, // new issues are open by default
+
}
+
}
+
+
type IssueComment struct {
+
Id int64
+
Did string
+
Rkey string
+
IssueAt string
+
ReplyTo *string
+
Body string
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
+
}
+
+
func (i *IssueComment) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
+
}
+
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
+
return tangled.RepoIssueComment{
+
Body: i.Body,
+
Issue: i.IssueAt,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
ReplyTo: i.ReplyTo,
+
}
+
}
+
+
func (i *IssueComment) IsTopLevel() bool {
+
return i.ReplyTo == nil
+
}
+
+
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
+
if err != nil {
+
created = time.Now()
+
}
+
+
ownerDid := did
+
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
+
return nil, err
+
}
+
+
comment := IssueComment{
+
Did: ownerDid,
+
Rkey: rkey,
+
Body: record.Body,
+
IssueAt: record.Issue,
+
ReplyTo: record.ReplyTo,
+
Created: created,
+
}
+
+
return &comment, nil
+
}
+14
appview/models/language.go
···
···
+
package models
+
+
import (
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type RepoLanguage struct {
+
Id int64
+
RepoAt syntax.ATURI
+
Ref string
+
IsDefaultRef bool
+
Language string
+
Bytes int64
+
}
-173
appview/db/oauth.go
···
-
package db
-
-
type OAuthRequest struct {
-
ID uint
-
AuthserverIss string
-
Handle string
-
State string
-
Did string
-
PdsUrl string
-
PkceVerifier string
-
DpopAuthserverNonce string
-
DpopPrivateJwk string
-
}
-
-
func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error {
-
_, err := e.Exec(`
-
insert into oauth_requests (
-
auth_server_iss,
-
state,
-
handle,
-
did,
-
pds_url,
-
pkce_verifier,
-
dpop_auth_server_nonce,
-
dpop_private_jwk
-
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
-
oauthRequest.AuthserverIss,
-
oauthRequest.State,
-
oauthRequest.Handle,
-
oauthRequest.Did,
-
oauthRequest.PdsUrl,
-
oauthRequest.PkceVerifier,
-
oauthRequest.DpopAuthserverNonce,
-
oauthRequest.DpopPrivateJwk,
-
)
-
return err
-
}
-
-
func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) {
-
var req OAuthRequest
-
err := e.QueryRow(`
-
select
-
id,
-
auth_server_iss,
-
handle,
-
state,
-
did,
-
pds_url,
-
pkce_verifier,
-
dpop_auth_server_nonce,
-
dpop_private_jwk
-
from oauth_requests
-
where state = ?`, state).Scan(
-
&req.ID,
-
&req.AuthserverIss,
-
&req.Handle,
-
&req.State,
-
&req.Did,
-
&req.PdsUrl,
-
&req.PkceVerifier,
-
&req.DpopAuthserverNonce,
-
&req.DpopPrivateJwk,
-
)
-
return req, err
-
}
-
-
func DeleteOAuthRequestByState(e Execer, state string) error {
-
_, err := e.Exec(`
-
delete from oauth_requests
-
where state = ?`, state)
-
return err
-
}
-
-
type OAuthSession struct {
-
ID uint
-
Handle string
-
Did string
-
PdsUrl string
-
AccessJwt string
-
RefreshJwt string
-
AuthServerIss string
-
DpopPdsNonce string
-
DpopAuthserverNonce string
-
DpopPrivateJwk string
-
Expiry string
-
}
-
-
func SaveOAuthSession(e Execer, session OAuthSession) error {
-
_, err := e.Exec(`
-
insert into oauth_sessions (
-
did,
-
handle,
-
pds_url,
-
access_jwt,
-
refresh_jwt,
-
auth_server_iss,
-
dpop_auth_server_nonce,
-
dpop_private_jwk,
-
expiry
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-
session.Did,
-
session.Handle,
-
session.PdsUrl,
-
session.AccessJwt,
-
session.RefreshJwt,
-
session.AuthServerIss,
-
session.DpopAuthserverNonce,
-
session.DpopPrivateJwk,
-
session.Expiry,
-
)
-
return err
-
}
-
-
func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error {
-
_, err := e.Exec(`
-
update oauth_sessions
-
set access_jwt = ?, refresh_jwt = ?, expiry = ?
-
where did = ?`,
-
accessJwt,
-
refreshJwt,
-
expiry,
-
did,
-
)
-
return err
-
}
-
-
func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) {
-
var session OAuthSession
-
err := e.QueryRow(`
-
select
-
id,
-
did,
-
handle,
-
pds_url,
-
access_jwt,
-
refresh_jwt,
-
auth_server_iss,
-
dpop_auth_server_nonce,
-
dpop_private_jwk,
-
expiry
-
from oauth_sessions
-
where did = ?`, did).Scan(
-
&session.ID,
-
&session.Did,
-
&session.Handle,
-
&session.PdsUrl,
-
&session.AccessJwt,
-
&session.RefreshJwt,
-
&session.AuthServerIss,
-
&session.DpopAuthserverNonce,
-
&session.DpopPrivateJwk,
-
&session.Expiry,
-
)
-
return &session, err
-
}
-
-
func DeleteOAuthSessionByDid(e Execer, did string) error {
-
_, err := e.Exec(`
-
delete from oauth_sessions
-
where did = ?`, did)
-
return err
-
}
-
-
func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error {
-
_, err := e.Exec(`
-
update oauth_sessions
-
set dpop_pds_nonce = ?
-
where did = ?`,
-
dpopPdsNonce,
-
did,
-
)
-
return err
-
}
···
+177
appview/models/profile.go
···
···
+
package models
+
+
import (
+
"fmt"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/api/tangled"
+
)
+
+
type Profile struct {
+
// ids
+
ID int
+
Did string
+
+
// data
+
Description string
+
IncludeBluesky bool
+
Location string
+
Links [5]string
+
Stats [2]VanityStat
+
PinnedRepos [6]syntax.ATURI
+
}
+
+
func (p Profile) IsLinksEmpty() bool {
+
for _, l := range p.Links {
+
if l != "" {
+
return false
+
}
+
}
+
return true
+
}
+
+
func (p Profile) IsStatsEmpty() bool {
+
for _, s := range p.Stats {
+
if s.Kind != "" {
+
return false
+
}
+
}
+
return true
+
}
+
+
func (p Profile) IsPinnedReposEmpty() bool {
+
for _, r := range p.PinnedRepos {
+
if r != "" {
+
return false
+
}
+
}
+
return true
+
}
+
+
type VanityStatKind string
+
+
const (
+
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
+
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
+
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
+
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
+
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
+
VanityStatRepositoryCount VanityStatKind = "repository-count"
+
)
+
+
func (v VanityStatKind) String() string {
+
switch v {
+
case VanityStatMergedPRCount:
+
return "Merged PRs"
+
case VanityStatClosedPRCount:
+
return "Closed PRs"
+
case VanityStatOpenPRCount:
+
return "Open PRs"
+
case VanityStatOpenIssueCount:
+
return "Open Issues"
+
case VanityStatClosedIssueCount:
+
return "Closed Issues"
+
case VanityStatRepositoryCount:
+
return "Repositories"
+
}
+
return ""
+
}
+
+
type VanityStat struct {
+
Kind VanityStatKind
+
Value uint64
+
}
+
+
func (p *Profile) ProfileAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
+
}
+
+
type RepoEvent struct {
+
Repo *Repo
+
Source *Repo
+
}
+
+
type ProfileTimeline struct {
+
ByMonth []ByMonth
+
}
+
+
func (p *ProfileTimeline) IsEmpty() bool {
+
if p == nil {
+
return true
+
}
+
+
for _, m := range p.ByMonth {
+
if !m.IsEmpty() {
+
return false
+
}
+
}
+
+
return true
+
}
+
+
type ByMonth struct {
+
RepoEvents []RepoEvent
+
IssueEvents IssueEvents
+
PullEvents PullEvents
+
}
+
+
func (b ByMonth) IsEmpty() bool {
+
return len(b.RepoEvents) == 0 &&
+
len(b.IssueEvents.Items) == 0 &&
+
len(b.PullEvents.Items) == 0
+
}
+
+
type IssueEvents struct {
+
Items []*Issue
+
}
+
+
type IssueEventStats struct {
+
Open int
+
Closed int
+
}
+
+
func (i IssueEvents) Stats() IssueEventStats {
+
var open, closed int
+
for _, issue := range i.Items {
+
if issue.Open {
+
open += 1
+
} else {
+
closed += 1
+
}
+
}
+
+
return IssueEventStats{
+
Open: open,
+
Closed: closed,
+
}
+
}
+
+
type PullEvents struct {
+
Items []*Pull
+
}
+
+
func (p PullEvents) Stats() PullEventStats {
+
var open, merged, closed int
+
for _, pull := range p.Items {
+
switch pull.State {
+
case PullOpen:
+
open += 1
+
case PullMerged:
+
merged += 1
+
case PullClosed:
+
closed += 1
+
}
+
}
+
+
return PullEventStats{
+
Open: open,
+
Merged: merged,
+
Closed: closed,
+
}
+
}
+
+
type PullEventStats struct {
+
Closed int
+
Open int
+
Merged int
+
}
+17 -139
appview/db/pipeline.go
···
"strings"
"time"
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
"github.com/go-git/go-git/v5/plumbing"
-
spindle "tangled.org/core/spindle/models"
-
"tangled.org/core/workflow"
)
-
type Pipeline struct {
-
Id int
-
Rkey string
-
Knot string
-
RepoOwner syntax.DID
-
RepoName string
-
TriggerId int
-
Sha string
-
Created time.Time
-
-
// populate when querying for reverse mappings
-
Trigger *Trigger
-
Statuses map[string]WorkflowStatus
-
}
-
-
type WorkflowStatus struct {
-
Data []PipelineStatus
-
}
-
-
func (w WorkflowStatus) Latest() PipelineStatus {
-
return w.Data[len(w.Data)-1]
-
}
-
-
// time taken by this workflow to reach an "end state"
-
func (w WorkflowStatus) TimeTaken() time.Duration {
-
var start, end *time.Time
-
for _, s := range w.Data {
-
if s.Status.IsStart() {
-
start = &s.Created
-
}
-
if s.Status.IsFinish() {
-
end = &s.Created
-
}
-
}
-
-
if start != nil && end != nil && end.After(*start) {
-
return end.Sub(*start)
-
}
-
-
return 0
-
}
-
-
func (p Pipeline) Counts() map[string]int {
-
m := make(map[string]int)
-
for _, w := range p.Statuses {
-
m[w.Latest().Status.String()] += 1
-
}
-
return m
-
}
-
-
func (p Pipeline) TimeTaken() time.Duration {
-
var s time.Duration
-
for _, w := range p.Statuses {
-
s += w.TimeTaken()
-
}
-
return s
-
}
-
-
func (p Pipeline) Workflows() []string {
-
var ws []string
-
for v := range p.Statuses {
-
ws = append(ws, v)
-
}
-
slices.Sort(ws)
-
return ws
-
}
-
-
// if we know that a spindle has picked up this pipeline, then it is Responding
-
func (p Pipeline) IsResponding() bool {
-
return len(p.Statuses) != 0
-
}
-
-
type Trigger struct {
-
Id int
-
Kind workflow.TriggerKind
-
-
// push trigger fields
-
PushRef *string
-
PushNewSha *string
-
PushOldSha *string
-
-
// pull request trigger fields
-
PRSourceBranch *string
-
PRTargetBranch *string
-
PRSourceSha *string
-
PRAction *string
-
}
-
-
func (t *Trigger) IsPush() bool {
-
return t != nil && t.Kind == workflow.TriggerKindPush
-
}
-
-
func (t *Trigger) IsPullRequest() bool {
-
return t != nil && t.Kind == workflow.TriggerKindPullRequest
-
}
-
-
func (t *Trigger) TargetRef() string {
-
if t.IsPush() {
-
return plumbing.ReferenceName(*t.PushRef).Short()
-
} else if t.IsPullRequest() {
-
return *t.PRTargetBranch
-
}
-
-
return ""
-
}
-
-
type PipelineStatus struct {
-
ID int
-
Spindle string
-
Rkey string
-
PipelineKnot string
-
PipelineRkey string
-
Created time.Time
-
Workflow string
-
Status spindle.StatusKind
-
Error *string
-
ExitCode int
-
}
-
-
func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) {
-
var pipelines []Pipeline
var conditions []string
var args []any
···
defer rows.Close()
for rows.Next() {
-
var pipeline Pipeline
var createdAt string
err = rows.Scan(
&pipeline.Id,
···
return pipelines, nil
}
-
func AddPipeline(e Execer, pipeline Pipeline) error {
args := []any{
pipeline.Rkey,
pipeline.Knot,
···
return err
}
-
func AddTrigger(e Execer, trigger Trigger) (int64, error) {
args := []any{
trigger.Kind,
trigger.PushRef,
···
return res.LastInsertId()
}
-
func AddPipelineStatus(e Execer, status PipelineStatus) error {
args := []any{
status.Spindle,
status.Rkey,
···
// this is a mega query, but the most useful one:
// get N pipelines, for each one get the latest status of its N workflows
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
}
defer rows.Close()
-
pipelines := make(map[string]Pipeline)
for rows.Next() {
-
var p Pipeline
-
var t Trigger
var created string
err := rows.Scan(
···
t.Id = p.TriggerId
p.Trigger = &t
-
p.Statuses = make(map[string]WorkflowStatus)
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
pipelines[k] = p
···
defer rows.Close()
for rows.Next() {
-
var ps PipelineStatus
var created string
err := rows.Scan(
···
}
statuses, _ := pipeline.Statuses[ps.Workflow]
if !ok {
-
pipeline.Statuses[ps.Workflow] = WorkflowStatus{}
}
// append
···
pipelines[key] = pipeline
}
-
var all []Pipeline
for _, p := range pipelines {
for _, s := range p.Statuses {
-
slices.SortFunc(s.Data, func(a, b PipelineStatus) int {
if a.Created.After(b.Created) {
return 1
}
···
}
// sort pipelines by date
-
slices.SortFunc(all, func(a, b Pipeline) int {
if a.Created.After(b.Created) {
return -1
}
···
"strings"
"time"
+
"tangled.org/core/appview/models"
)
+
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
+
var pipelines []models.Pipeline
var conditions []string
var args []any
···
defer rows.Close()
for rows.Next() {
+
var pipeline models.Pipeline
var createdAt string
err = rows.Scan(
&pipeline.Id,
···
return pipelines, nil
}
+
func AddPipeline(e Execer, pipeline models.Pipeline) error {
args := []any{
pipeline.Rkey,
pipeline.Knot,
···
return err
}
+
func AddTrigger(e Execer, trigger models.Trigger) (int64, error) {
args := []any{
trigger.Kind,
trigger.PushRef,
···
return res.LastInsertId()
}
+
func AddPipelineStatus(e Execer, status models.PipelineStatus) error {
args := []any{
status.Spindle,
status.Rkey,
···
// this is a mega query, but the most useful one:
// get N pipelines, for each one get the latest status of its N workflows
+
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
}
defer rows.Close()
+
pipelines := make(map[string]models.Pipeline)
for rows.Next() {
+
var p models.Pipeline
+
var t models.Trigger
var created string
err := rows.Scan(
···
t.Id = p.TriggerId
p.Trigger = &t
+
p.Statuses = make(map[string]models.WorkflowStatus)
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
pipelines[k] = p
···
defer rows.Close()
for rows.Next() {
+
var ps models.PipelineStatus
var created string
err := rows.Scan(
···
}
statuses, _ := pipeline.Statuses[ps.Workflow]
if !ok {
+
pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{}
}
// append
···
pipelines[key] = pipeline
}
+
var all []models.Pipeline
for _, p := range pipelines {
for _, s := range p.Statuses {
+
slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int {
if a.Created.After(b.Created) {
return 1
}
···
}
// sort pipelines by date
+
slices.SortFunc(all, func(a, b models.Pipeline) int {
if a.Created.After(b.Created) {
return -1
}
+130
appview/models/pipeline.go
···
···
+
package models
+
+
import (
+
"slices"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/go-git/go-git/v5/plumbing"
+
spindle "tangled.org/core/spindle/models"
+
"tangled.org/core/workflow"
+
)
+
+
type Pipeline struct {
+
Id int
+
Rkey string
+
Knot string
+
RepoOwner syntax.DID
+
RepoName string
+
TriggerId int
+
Sha string
+
Created time.Time
+
+
// populate when querying for reverse mappings
+
Trigger *Trigger
+
Statuses map[string]WorkflowStatus
+
}
+
+
type WorkflowStatus struct {
+
Data []PipelineStatus
+
}
+
+
func (w WorkflowStatus) Latest() PipelineStatus {
+
return w.Data[len(w.Data)-1]
+
}
+
+
// time taken by this workflow to reach an "end state"
+
func (w WorkflowStatus) TimeTaken() time.Duration {
+
var start, end *time.Time
+
for _, s := range w.Data {
+
if s.Status.IsStart() {
+
start = &s.Created
+
}
+
if s.Status.IsFinish() {
+
end = &s.Created
+
}
+
}
+
+
if start != nil && end != nil && end.After(*start) {
+
return end.Sub(*start)
+
}
+
+
return 0
+
}
+
+
func (p Pipeline) Counts() map[string]int {
+
m := make(map[string]int)
+
for _, w := range p.Statuses {
+
m[w.Latest().Status.String()] += 1
+
}
+
return m
+
}
+
+
func (p Pipeline) TimeTaken() time.Duration {
+
var s time.Duration
+
for _, w := range p.Statuses {
+
s += w.TimeTaken()
+
}
+
return s
+
}
+
+
func (p Pipeline) Workflows() []string {
+
var ws []string
+
for v := range p.Statuses {
+
ws = append(ws, v)
+
}
+
slices.Sort(ws)
+
return ws
+
}
+
+
// if we know that a spindle has picked up this pipeline, then it is Responding
+
func (p Pipeline) IsResponding() bool {
+
return len(p.Statuses) != 0
+
}
+
+
type Trigger struct {
+
Id int
+
Kind workflow.TriggerKind
+
+
// push trigger fields
+
PushRef *string
+
PushNewSha *string
+
PushOldSha *string
+
+
// pull request trigger fields
+
PRSourceBranch *string
+
PRTargetBranch *string
+
PRSourceSha *string
+
PRAction *string
+
}
+
+
func (t *Trigger) IsPush() bool {
+
return t != nil && t.Kind == workflow.TriggerKindPush
+
}
+
+
func (t *Trigger) IsPullRequest() bool {
+
return t != nil && t.Kind == workflow.TriggerKindPullRequest
+
}
+
+
func (t *Trigger) TargetRef() string {
+
if t.IsPush() {
+
return plumbing.ReferenceName(*t.PushRef).Short()
+
} else if t.IsPullRequest() {
+
return *t.PRTargetBranch
+
}
+
+
return ""
+
}
+
+
type PipelineStatus struct {
+
ID int
+
Spindle string
+
Rkey string
+
PipelineKnot string
+
PipelineRkey string
+
Created time.Time
+
Workflow string
+
Status spindle.StatusKind
+
Error *string
+
ExitCode int
+
}
+25
appview/models/pubkey.go
···
···
+
package models
+
+
import (
+
"encoding/json"
+
"time"
+
)
+
+
type PublicKey struct {
+
Did string `json:"did"`
+
Key string `json:"key"`
+
Name string `json:"name"`
+
Rkey string `json:"rkey"`
+
Created *time.Time
+
}
+
+
func (p PublicKey) MarshalJSON() ([]byte, error) {
+
type Alias PublicKey
+
return json.Marshal(&struct {
+
Created string `json:"created"`
+
*Alias
+
}{
+
Created: p.Created.Format(time.RFC3339),
+
Alias: (*Alias)(&p),
+
})
+
}
+14
appview/models/punchcard.go
···
···
+
package models
+
+
import "time"
+
+
type Punch struct {
+
Did string
+
Date time.Time
+
Count int
+
}
+
+
type Punchcard struct {
+
Total int
+
Punches []Punch
+
}
+44
appview/models/registration.go
···
···
+
package models
+
+
import "time"
+
+
// Registration represents a knot registration. Knot would've been a better
+
// name but we're stuck with this for historical reasons.
+
type Registration struct {
+
Id int64
+
Domain string
+
ByDid string
+
Created *time.Time
+
Registered *time.Time
+
NeedsUpgrade bool
+
}
+
+
func (r *Registration) Status() Status {
+
if r.NeedsUpgrade {
+
return NeedsUpgrade
+
} else if r.Registered != nil {
+
return Registered
+
} else {
+
return Pending
+
}
+
}
+
+
func (r *Registration) IsRegistered() bool {
+
return r.Status() == Registered
+
}
+
+
func (r *Registration) IsNeedsUpgrade() bool {
+
return r.Status() == NeedsUpgrade
+
}
+
+
func (r *Registration) IsPending() bool {
+
return r.Status() == Pending
+
}
+
+
type Status uint32
+
+
const (
+
Registered Status = iota
+
Pending
+
NeedsUpgrade
+
)
+14 -63
appview/db/reaction.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
)
-
type ReactionKind string
-
-
const (
-
Like ReactionKind = "๐Ÿ‘"
-
Unlike ReactionKind = "๐Ÿ‘Ž"
-
Laugh ReactionKind = "๐Ÿ˜†"
-
Celebration ReactionKind = "๐ŸŽ‰"
-
Confused ReactionKind = "๐Ÿซค"
-
Heart ReactionKind = "โค๏ธ"
-
Rocket ReactionKind = "๐Ÿš€"
-
Eyes ReactionKind = "๐Ÿ‘€"
-
)
-
-
func (rk ReactionKind) String() string {
-
return string(rk)
-
}
-
-
var OrderedReactionKinds = []ReactionKind{
-
Like,
-
Unlike,
-
Laugh,
-
Celebration,
-
Confused,
-
Heart,
-
Rocket,
-
Eyes,
-
}
-
-
func ParseReactionKind(raw string) (ReactionKind, bool) {
-
k, ok := (map[string]ReactionKind{
-
"๐Ÿ‘": Like,
-
"๐Ÿ‘Ž": Unlike,
-
"๐Ÿ˜†": Laugh,
-
"๐ŸŽ‰": Celebration,
-
"๐Ÿซค": Confused,
-
"โค๏ธ": Heart,
-
"๐Ÿš€": Rocket,
-
"๐Ÿ‘€": Eyes,
-
})[raw]
-
return k, ok
-
}
-
-
type Reaction struct {
-
ReactedByDid string
-
ThreadAt syntax.ATURI
-
Created time.Time
-
Rkey string
-
Kind ReactionKind
-
}
-
-
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error {
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
return err
}
// Get a reaction record
-
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) {
query := `
select reacted_by_did, thread_at, created, rkey
from reactions
where reacted_by_did = ? and thread_at = ? and kind = ?`
row := e.QueryRow(query, reactedByDid, threadAt, kind)
-
var reaction Reaction
var created string
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
if err != nil {
···
}
// Remove a reaction
-
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error {
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
return err
}
···
return err
}
-
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) {
count := 0
err := e.QueryRow(
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
···
return count, nil
}
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) {
-
countMap := map[ReactionKind]int{}
-
for _, kind := range OrderedReactionKinds {
count, err := GetReactionCount(e, threadAt, kind)
if err != nil {
-
return map[ReactionKind]int{}, nil
}
countMap[kind] = count
}
return countMap, nil
}
-
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool {
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
return false
} else {
···
}
}
-
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool {
-
statusMap := map[ReactionKind]bool{}
-
for _, kind := range OrderedReactionKinds {
count := GetReactionStatus(e, userDid, threadAt, kind)
statusMap[kind] = count
}
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/appview/models"
)
+
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error {
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
return err
}
// Get a reaction record
+
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) {
query := `
select reacted_by_did, thread_at, created, rkey
from reactions
where reacted_by_did = ? and thread_at = ? and kind = ?`
row := e.QueryRow(query, reactedByDid, threadAt, kind)
+
var reaction models.Reaction
var created string
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
if err != nil {
···
}
// Remove a reaction
+
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error {
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
return err
}
···
return err
}
+
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) {
count := 0
err := e.QueryRow(
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
···
return count, nil
}
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
+
countMap := map[models.ReactionKind]int{}
+
for _, kind := range models.OrderedReactionKinds {
count, err := GetReactionCount(e, threadAt, kind)
if err != nil {
+
return map[models.ReactionKind]int{}, nil
}
countMap[kind] = count
}
return countMap, nil
}
+
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
return false
} else {
···
}
}
+
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool {
+
statusMap := map[models.ReactionKind]bool{}
+
for _, kind := range models.OrderedReactionKinds {
count := GetReactionStatus(e, userDid, threadAt, kind)
statusMap[kind] = count
}
+57
appview/models/reaction.go
···
···
+
package models
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type ReactionKind string
+
+
const (
+
Like ReactionKind = "๐Ÿ‘"
+
Unlike ReactionKind = "๐Ÿ‘Ž"
+
Laugh ReactionKind = "๐Ÿ˜†"
+
Celebration ReactionKind = "๐ŸŽ‰"
+
Confused ReactionKind = "๐Ÿซค"
+
Heart ReactionKind = "โค๏ธ"
+
Rocket ReactionKind = "๐Ÿš€"
+
Eyes ReactionKind = "๐Ÿ‘€"
+
)
+
+
func (rk ReactionKind) String() string {
+
return string(rk)
+
}
+
+
var OrderedReactionKinds = []ReactionKind{
+
Like,
+
Unlike,
+
Laugh,
+
Celebration,
+
Confused,
+
Heart,
+
Rocket,
+
Eyes,
+
}
+
+
func ParseReactionKind(raw string) (ReactionKind, bool) {
+
k, ok := (map[string]ReactionKind{
+
"๐Ÿ‘": Like,
+
"๐Ÿ‘Ž": Unlike,
+
"๐Ÿ˜†": Laugh,
+
"๐ŸŽ‰": Celebration,
+
"๐Ÿซค": Confused,
+
"โค๏ธ": Heart,
+
"๐Ÿš€": Rocket,
+
"๐Ÿ‘€": Eyes,
+
})[raw]
+
return k, ok
+
}
+
+
type Reaction struct {
+
ReactedByDid string
+
ThreadAt syntax.ATURI
+
Created time.Time
+
Rkey string
+
Kind ReactionKind
+
}
+10
appview/models/signup.go
···
···
+
package models
+
+
import "time"
+
+
type InflightSignup struct {
+
Id int64
+
Email string
+
InviteCode string
+
Created time.Time
+
}
+25
appview/models/spindle.go
···
···
+
package models
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type Spindle struct {
+
Id int
+
Owner syntax.DID
+
Instance string
+
Verified *time.Time
+
Created time.Time
+
NeedsUpgrade bool
+
}
+
+
type SpindleMember struct {
+
Id int
+
Did syntax.DID // owner of the record
+
Rkey string // rkey of the record
+
Instance string
+
Subject syntax.DID // the member being added
+
Created time.Time
+
}
+17
appview/models/star.go
···
···
+
package models
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type Star struct {
+
StarredByDid string
+
RepoAt syntax.ATURI
+
Created time.Time
+
Rkey string
+
+
// optionally, populate this when querying for reverse mappings
+
Repo *Repo
+
}
+1 -1
appview/state/star.go
···
}
log.Println("created atproto record: ", resp.Uri)
-
star := &db.Star{
StarredByDid: currentUser.Did,
RepoAt: subjectUri,
Rkey: rkey,
···
}
log.Println("created atproto record: ", resp.Uri)
+
star := &models.Star{
StarredByDid: currentUser.Did,
RepoAt: subjectUri,
Rkey: rkey,
+95
appview/models/string.go
···
···
+
package models
+
+
import (
+
"bytes"
+
"fmt"
+
"io"
+
"strings"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.org/core/api/tangled"
+
)
+
+
type String struct {
+
Did syntax.DID
+
Rkey string
+
+
Filename string
+
Description string
+
Contents string
+
Created time.Time
+
Edited *time.Time
+
}
+
+
func (s *String) StringAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
+
}
+
+
func (s *String) AsRecord() tangled.String {
+
return tangled.String{
+
Filename: s.Filename,
+
Description: s.Description,
+
Contents: s.Contents,
+
CreatedAt: s.Created.Format(time.RFC3339),
+
}
+
}
+
+
func StringFromRecord(did, rkey string, record tangled.String) String {
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
+
if err != nil {
+
created = time.Now()
+
}
+
return String{
+
Did: syntax.DID(did),
+
Rkey: rkey,
+
Filename: record.Filename,
+
Description: record.Description,
+
Contents: record.Contents,
+
Created: created,
+
}
+
}
+
+
type StringStats struct {
+
LineCount uint64
+
ByteCount uint64
+
}
+
+
func (s String) Stats() StringStats {
+
lineCount, err := countLines(strings.NewReader(s.Contents))
+
if err != nil {
+
// non-fatal
+
// TODO: log this?
+
}
+
+
return StringStats{
+
LineCount: uint64(lineCount),
+
ByteCount: uint64(len(s.Contents)),
+
}
+
}
+
+
func countLines(r io.Reader) (int, error) {
+
buf := make([]byte, 32*1024)
+
bufLen := 0
+
count := 0
+
nl := []byte{'\n'}
+
+
for {
+
c, err := r.Read(buf)
+
if c > 0 {
+
bufLen += c
+
}
+
count += bytes.Count(buf[:c], nl)
+
+
switch {
+
case err == io.EOF:
+
/* handle last line not having a newline at the end */
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
+
count++
+
}
+
return count, nil
+
case err != nil:
+
return 0, err
+
}
+
}
+
}
+3 -2
appview/strings/strings.go
···
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
"tangled.org/core/appview/middleware"
"tangled.org/core/appview/notify"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
···
description := r.FormValue("description")
// construct new string from form values
-
entry := db.String{
Did: first.Did,
Rkey: first.Rkey,
Filename: filename,
···
description := r.FormValue("description")
-
string := db.String{
Did: syntax.DID(user.Did),
Rkey: tid.TID(),
Filename: filename,
···
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
"tangled.org/core/appview/middleware"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/notify"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
···
description := r.FormValue("description")
// construct new string from form values
+
entry := models.String{
Did: first.Did,
Rkey: first.Rkey,
Filename: filename,
···
description := r.FormValue("description")
+
string := models.String{
Did: syntax.DID(user.Did),
Rkey: tid.TID(),
Filename: filename,
+27
appview/validator/string.go
···
···
+
package validator
+
+
import (
+
"errors"
+
"fmt"
+
"unicode/utf8"
+
+
"tangled.org/core/appview/models"
+
)
+
+
func (v *Validator) ValidateString(s *models.String) error {
+
var err error
+
+
if utf8.RuneCountInString(s.Filename) > 140 {
+
err = errors.Join(err, fmt.Errorf("filename too long"))
+
}
+
+
if utf8.RuneCountInString(s.Description) > 280 {
+
err = errors.Join(err, fmt.Errorf("description too long"))
+
}
+
+
if len(s.Contents) == 0 {
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
+
}
+
+
return err
+
}
+11 -32
appview/db/timeline.go
···
import (
"sort"
-
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
)
-
type TimelineEvent struct {
-
*models.Repo
-
*models.Follow
-
*models.Star
-
-
EventAt time.Time
-
-
// optional: populate only if Repo is a fork
-
Source *models.Repo
-
-
// optional: populate only if event is Follow
-
*models.Profile
-
*models.FollowStats
-
*models.FollowStatus
-
-
// optional: populate only if event is Repo
-
IsStarred bool
-
StarCount int64
-
}
-
// TODO: this gathers heterogenous events from different sources and aggregates
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
-
var events []TimelineEvent
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
if err != nil {
···
return isStarred, starCount
}
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
repos, err := GetRepos(e, limit)
if err != nil {
return nil, err
···
return nil, err
}
-
var events []TimelineEvent
for _, r := range repos {
var source *models.Repo
if r.Source != "" {
···
isStarred, starCount := getRepoStarInfo(&r, starStatuses)
-
events = append(events, TimelineEvent{
Repo: &r,
EventAt: r.Created,
Source: source,
···
return events, nil
}
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
stars, err := GetStars(e, limit)
if err != nil {
return nil, err
···
return nil, err
}
-
var events []TimelineEvent
for _, s := range stars {
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
-
events = append(events, TimelineEvent{
Star: &s,
EventAt: s.Created,
IsStarred: isStarred,
···
return events, nil
}
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
follows, err := GetFollows(e, limit)
if err != nil {
return nil, err
···
}
}
-
var events []TimelineEvent
for _, f := range follows {
profile, _ := profiles[f.SubjectDid]
followStatMap, _ := followStatMap[f.SubjectDid]
···
followStatus = followStatuses[f.SubjectDid]
}
-
events = append(events, TimelineEvent{
Follow: &f,
Profile: profile,
FollowStats: &followStatMap,
···
import (
"sort"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
)
// TODO: this gathers heterogenous events from different sources and aggregates
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
+
var events []models.TimelineEvent
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
if err != nil {
···
return isStarred, starCount
}
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
repos, err := GetRepos(e, limit)
if err != nil {
return nil, err
···
return nil, err
}
+
var events []models.TimelineEvent
for _, r := range repos {
var source *models.Repo
if r.Source != "" {
···
isStarred, starCount := getRepoStarInfo(&r, starStatuses)
+
events = append(events, models.TimelineEvent{
Repo: &r,
EventAt: r.Created,
Source: source,
···
return events, nil
}
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
stars, err := GetStars(e, limit)
if err != nil {
return nil, err
···
return nil, err
}
+
var events []models.TimelineEvent
for _, s := range stars {
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
+
events = append(events, models.TimelineEvent{
Star: &s,
EventAt: s.Created,
IsStarred: isStarred,
···
return events, nil
}
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
follows, err := GetFollows(e, limit)
if err != nil {
return nil, err
···
}
}
+
var events []models.TimelineEvent
for _, f := range follows {
profile, _ := profiles[f.SubjectDid]
followStatMap, _ := followStatMap[f.SubjectDid]
···
followStatus = followStatuses[f.SubjectDid]
}
+
events = append(events, models.TimelineEvent{
Follow: &f,
Profile: profile,
FollowStats: &followStatMap,
+23
appview/models/timeline.go
···
···
+
package models
+
+
import "time"
+
+
type TimelineEvent struct {
+
*Repo
+
*Follow
+
*Star
+
+
EventAt time.Time
+
+
// optional: populate only if Repo is a fork
+
Source *Repo
+
+
// optional: populate only if event is Follow
+
*Profile
+
*FollowStats
+
*FollowStatus
+
+
// optional: populate only if event is Repo
+
IsStarred bool
+
StarCount int64
+
}
+1 -1
appview/db/label.go
···
defs[l.AtUri().String()] = &l
}
-
return &models.LabelApplicationCtx{defs}, nil
}
···
defs[l.AtUri().String()] = &l
}
+
return &models.LabelApplicationCtx{Defs: defs}, nil
}
+15 -15
appview/pages/funcmap.go
···
"relTimeFmt": humanize.Time,
"shortRelTimeFmt": func(t time.Time) string {
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
-
{time.Second, "now", time.Second},
-
{2 * time.Second, "1s %s", 1},
-
{time.Minute, "%ds %s", time.Second},
-
{2 * time.Minute, "1min %s", 1},
-
{time.Hour, "%dmin %s", time.Minute},
-
{2 * time.Hour, "1hr %s", 1},
-
{humanize.Day, "%dhrs %s", time.Hour},
-
{2 * humanize.Day, "1d %s", 1},
-
{20 * humanize.Day, "%dd %s", humanize.Day},
-
{8 * humanize.Week, "%dw %s", humanize.Week},
-
{humanize.Year, "%dmo %s", humanize.Month},
-
{18 * humanize.Month, "1y %s", 1},
-
{2 * humanize.Year, "2y %s", 1},
-
{humanize.LongTime, "%dy %s", humanize.Year},
-
{math.MaxInt64, "a long while %s", 1},
})
},
"longTimeFmt": func(t time.Time) string {
···
"relTimeFmt": humanize.Time,
"shortRelTimeFmt": func(t time.Time) string {
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
+
{D: time.Second, Format: "now", DivBy: time.Second},
+
{D: 2 * time.Second, Format: "1s %s", DivBy: 1},
+
{D: time.Minute, Format: "%ds %s", DivBy: time.Second},
+
{D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
+
{D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
+
{D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
+
{D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
+
{D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
+
{D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
+
{D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
+
{D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
+
{D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
+
{D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
+
{D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
+
{D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
})
},
"longTimeFmt": func(t time.Time) string {
+30
appview/pages/funcmap_test.go
···
···
+
package pages
+
+
import (
+
"html/template"
+
"tangled.org/core/appview/config"
+
"tangled.org/core/idresolver"
+
"testing"
+
)
+
+
func TestPages_funcMap(t *testing.T) {
+
tests := []struct {
+
name string // description of this test case
+
// Named input parameters for receiver constructor.
+
config *config.Config
+
res *idresolver.Resolver
+
want template.FuncMap
+
}{
+
// TODO: Add test cases.
+
}
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
p := NewPages(tt.config, tt.res)
+
got := p.funcMap()
+
// TODO: update the condition below to compare got with tt.want.
+
if true {
+
t.Errorf("funcMap() = %v, want %v", got, tt.want)
+
}
+
})
+
}
+
}
+40 -14
appview/repo/artifact.go
···
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
···
})
}
-
// TODO: proper statuses here on early exit
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
-
tagParam := chi.URLParam(r, "tag")
-
filename := chi.URLParam(r, "file")
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
return
}
tag, err := rp.resolveTag(r.Context(), f, tagParam)
if err != nil {
log.Println("failed to resolve tag", err)
···
return
}
-
client, err := rp.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
return
-
}
-
artifacts, err := db.GetArtifact(
rp.db,
db.FilterEq("repo_at", f.RepoAt()),
···
)
if err != nil {
log.Println("failed to get artifacts", err)
return
}
if len(artifacts) != 1 {
-
log.Printf("too many or too little artifacts found")
return
}
artifact := artifacts[0]
-
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
if err != nil {
-
log.Println("failed to get blob from pds", err)
return
}
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
-
w.Write(getBlobResp)
}
// TODO: proper statuses here on early exit
···
"context"
"encoding/json"
"fmt"
+
"io"
"log"
"net/http"
"net/url"
···
})
}
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
+
http.Error(w, "failed to resolve repo", http.StatusInternalServerError)
return
}
+
tagParam := chi.URLParam(r, "tag")
+
filename := chi.URLParam(r, "file")
+
tag, err := rp.resolveTag(r.Context(), f, tagParam)
if err != nil {
log.Println("failed to resolve tag", err)
···
return
}
artifacts, err := db.GetArtifact(
rp.db,
db.FilterEq("repo_at", f.RepoAt()),
···
)
if err != nil {
log.Println("failed to get artifacts", err)
+
http.Error(w, "failed to get artifact", http.StatusInternalServerError)
return
}
+
if len(artifacts) != 1 {
+
log.Printf("too many or too few artifacts found")
+
http.Error(w, "artifact not found", http.StatusNotFound)
return
}
artifact := artifacts[0]
+
ownerPds := f.OwnerId.PDSEndpoint()
+
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
+
q := url.Query()
+
q.Set("cid", artifact.BlobCid.String())
+
q.Set("did", artifact.Did)
+
url.RawQuery = q.Encode()
+
+
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
if err != nil {
+
log.Println("failed to create request", err)
+
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
+
req.Header.Set("Content-Type", "application/json")
+
resp, err := http.DefaultClient.Do(req)
+
if err != nil {
+
log.Println("failed to make request", err)
+
http.Error(w, "failed to make request to PDS", http.StatusInternalServerError)
+
return
+
}
+
defer resp.Body.Close()
+
+
// copy status code and relevant headers from upstream response
+
w.WriteHeader(resp.StatusCode)
+
for key, values := range resp.Header {
+
for _, v := range values {
+
w.Header().Add(key, v)
+
}
+
}
+
+
// stream the body directly to the client
+
if _, err := io.Copy(w, resp.Body); err != nil {
+
log.Println("error streaming response to client:", err)
+
}
}
// TODO: proper statuses here on early exit
+36 -6
appview/pages/templates/repo/settings/general.html
···
{{ define "defaultLabelSettings" }}
<div class="flex flex-col gap-2">
-
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
-
<p class="text-gray-500 dark:text-gray-400">
-
Manage your issues and pulls by creating labels to categorize them. Only
-
repository owners may configure labels. You may choose to subscribe to
-
default labels, or create entirely custom labels.
-
</p>
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
{{ range .DefaultLabels }}
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
···
{{ define "defaultLabelSettings" }}
<div class="flex flex-col gap-2">
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Manage your issues and pulls by creating labels to categorize them. Only
+
repository owners may configure labels. You may choose to subscribe to
+
default labels, or create entirely custom labels.
+
<p>
+
</div>
+
<form class="col-span-1 md:col-span-1 md:justify-self-end">
+
{{ $title := "Unubscribe from all labels" }}
+
{{ $icon := "x" }}
+
{{ $text := "unsubscribe all" }}
+
{{ $action := "unsubscribe" }}
+
{{ if $.ShouldSubscribeAll }}
+
{{ $title = "Subscribe to all labels" }}
+
{{ $icon = "check-check" }}
+
{{ $text = "subscribe all" }}
+
{{ $action = "subscribe" }}
+
{{ end }}
+
{{ range .DefaultLabels }}
+
<input type="hidden" name="label" value="{{ .AtUri.String }}">
+
{{ end }}
+
<button
+
type="submit"
+
title="{{$title}}"
+
class="btn flex items-center gap-2 group"
+
hx-swap="none"
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}>
+
{{ i $icon "size-4" }}
+
{{ $text }}
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</form>
+
</div>
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
{{ range .DefaultLabels }}
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
···
{{ $event := index . 1 }}
{{ $follow := $event.Follow }}
{{ $profile := $event.Profile }}
-
{{ $stat := $event.FollowStats }}
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
{{ template "user/fragments/picHandleLink" $subjectHandle }}
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
</div>
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4">
-
<div class="flex items-center gap-4 flex-1">
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
-
</div>
-
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
-
<a href="/{{ $subjectHandle }}">
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
-
</a>
-
{{ with $profile }}
-
{{ with .Description }}
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
-
{{ end }}
-
{{ end }}
-
{{ with $stat }}
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
-
<span class="select-none after:content-['ยท']"></span>
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
-
<div class="flex-shrink-0 w-fit ml-auto">
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
-
</div>
-
{{ end }}
-
</div>
{{ end }}
···
{{ $event := index . 1 }}
{{ $follow := $event.Follow }}
{{ $profile := $event.Profile }}
+
{{ $followStats := $event.FollowStats }}
+
{{ $followStatus := $event.FollowStatus }}
{{ $userHandle := resolve $follow.UserDid }}
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
{{ template "user/fragments/picHandleLink" $subjectHandle }}
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
</div>
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $root.LoggedInUser
+
"UserDid" $follow.SubjectDid
+
"Profile" $profile
+
"FollowStatus" $followStatus
+
"FollowersCount" $followStats.Followers
+
"FollowingCount" $followStats.Following) }}
{{ end }}
+8 -1
appview/pages/templates/user/followers.html
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Followers }}
-
{{ template "user/fragments/followCard" . }}
{{ else }}
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
{{ end }}
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Followers }}
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $.LoggedInUser
+
"UserDid" .UserDid
+
"Profile" .Profile
+
"FollowStatus" .FollowStatus
+
"FollowersCount" .FollowersCount
+
"FollowingCount" .FollowingCount) }}
{{ else }}
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
{{ end }}
+8 -1
appview/pages/templates/user/following.html
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Following }}
-
{{ template "user/fragments/followCard" . }}
{{ else }}
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
{{ end }}
···
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Following }}
+
{{ template "user/fragments/followCard"
+
(dict
+
"LoggedInUser" $.LoggedInUser
+
"UserDid" .UserDid
+
"Profile" .Profile
+
"FollowStatus" .FollowStatus
+
"FollowersCount" .FollowersCount
+
"FollowingCount" .FollowingCount) }}
{{ else }}
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
{{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
···
{{ define "user/fragments/follow" }}
<button id="{{ normalizeForHtmlId .UserDid }}"
-
class="btn mt-2 flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
hx-post="/follow?subject={{.UserDid}}"
···
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
{{ end }}
···
{{ define "user/fragments/follow" }}
<button id="{{ normalizeForHtmlId .UserDid }}"
+
class="btn w-full flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
hx-post="/follow?subject={{.UserDid}}"
···
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
+
{{ i "user-round-plus" "w-4 h-4" }} follow
+
{{ else }}
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
+
{{ end }}
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
{{ end }}
+58 -3
appview/posthog/notifier.go appview/notify/posthog/notifier.go
···
-
package posthog_service
import (
"context"
···
}
}
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: follow.UserDid,
···
}
}
-
func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: string.Did.String(),
-
Event: "create_string",
Properties: posthog.Properties{"rkey": string.Rkey},
})
if err != nil {
log.Println("failed to enqueue posthog event:", err)
}
}
···
+
package posthog
import (
"context"
···
}
}
+
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: pull.OwnerDid,
+
Event: "pull_closed",
+
Properties: posthog.Properties{
+
"repo_at": pull.RepoAt,
+
"pull_id": pull.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: follow.UserDid,
···
}
}
+
func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) {
err := n.client.Enqueue(posthog.Capture{
DistinctId: string.Did.String(),
+
Event: "new_string",
Properties: posthog.Properties{"rkey": string.Rkey},
})
if err != nil {
log.Println("failed to enqueue posthog event:", err)
}
}
+
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: comment.Did,
+
Event: "new_issue_comment",
+
Properties: posthog.Properties{
+
"issue_at": comment.IssueAt,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: issue.Did,
+
Event: "issue_closed",
+
Properties: posthog.Properties{
+
"repo_at": issue.RepoAt.String(),
+
"issue_id": issue.IssueId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
+
err := n.client.Enqueue(posthog.Capture{
+
DistinctId: pull.OwnerDid,
+
Event: "pull_merged",
+
Properties: posthog.Properties{
+
"repo_at": pull.RepoAt,
+
"pull_id": pull.PullId,
+
},
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+36 -6
appview/db/repos.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/appview/models"
)
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
repoMap := make(map[syntax.ATURI]*models.Repo)
···
repoQuery := fmt.Sprintf(
`select
did,
name,
knot,
···
var description, source, spindle sql.NullString
err := rows.Scan(
&repo.Did,
&repo.Name,
&repo.Knot,
···
var repo models.Repo
var nullableDescription sql.NullString
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
var createdAt string
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
var repos []models.Repo
rows, err := e.Query(
-
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
from repos r
left join collaborators c on r.at_uri = c.repo_at
where (r.did = ? or c.subject_did = ?)
···
var nullableDescription sql.NullString
var nullableSource sql.NullString
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
···
var nullableSource sql.NullString
row := e.QueryRow(
-
`select did, name, knot, rkey, description, created, source
from repos
where did = ? and name = ? and source is not null and source != ''`,
did, name,
)
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.org/core/api/tangled"
"tangled.org/core/appview/models"
)
+
type Repo struct {
+
Id int64
+
Did string
+
Name string
+
Knot string
+
Rkey string
+
Created time.Time
+
Description string
+
Spindle string
+
+
// optionally, populate this when querying for reverse mappings
+
RepoStats *models.RepoStats
+
+
// optional
+
Source string
+
}
+
+
func (r Repo) RepoAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
+
}
+
+
func (r Repo) DidSlashRepo() string {
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
+
return p
+
}
+
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
repoMap := make(map[syntax.ATURI]*models.Repo)
···
repoQuery := fmt.Sprintf(
`select
+
id,
did,
name,
knot,
···
var description, source, spindle sql.NullString
err := rows.Scan(
+
&repo.Id,
&repo.Did,
&repo.Name,
&repo.Knot,
···
var repo models.Repo
var nullableDescription sql.NullString
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
var createdAt string
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
var repos []models.Repo
rows, err := e.Query(
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
from repos r
left join collaborators c on r.at_uri = c.repo_at
where (r.did = ? or c.subject_did = ?)
···
var nullableDescription sql.NullString
var nullableSource sql.NullString
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
···
var nullableSource sql.NullString
row := e.QueryRow(
+
`select id, did, name, knot, rkey, description, created, source
from repos
where did = ? and name = ? and source is not null and source != ''`,
did, name,
)
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
+12
appview/notify/merged_notifier.go
···
}
}
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
for _, notifier := range m.notifiers {
notifier.UpdateProfile(ctx, profile)
···
}
}
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
+
for _, notifier := range m.notifiers {
+
notifier.NewPullMerged(ctx, pull)
+
}
+
}
+
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
+
for _, notifier := range m.notifiers {
+
notifier.NewPullClosed(ctx, pull)
+
}
+
}
+
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
for _, notifier := range m.notifiers {
notifier.UpdateProfile(ctx, profile)
+4 -11
appview/pages/templates/errors/500.html
···
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
<div class="mb-6">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
</div>
</div>
···
500 &mdash; internal server error
</h1>
<p class="text-gray-600 dark:text-gray-300">
-
Something went wrong on our end. We've been notified and are working to fix the issue.
-
</p>
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
-
<div class="flex items-center gap-2">
-
{{ i "info" "w-4 h-4" }}
-
<span class="font-medium">we're on it!</span>
-
</div>
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
-
</div>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
<button onclick="location.reload()" class="btn-create gap-2">
{{ i "refresh-cw" "w-4 h-4" }}
try again
</button>
<a href="/" class="btn no-underline hover:no-underline gap-2">
-
{{ i "home" "w-4 h-4" }}
back to home
</a>
</div>
···
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
<div class="mb-6">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
</div>
</div>
···
500 &mdash; internal server error
</h1>
<p class="text-gray-600 dark:text-gray-300">
+
We encountered an error while processing your request. Please try again later.
+
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
<button onclick="location.reload()" class="btn-create gap-2">
{{ i "refresh-cw" "w-4 h-4" }}
try again
</button>
<a href="/" class="btn no-underline hover:no-underline gap-2">
+
{{ i "arrow-left" "w-4 h-4" }}
back to home
</a>
</div>
+173
appview/pages/templates/user/settings/notifications.html
···
···
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
+
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Settings</p>
+
</div>
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
+
<div class="col-span-1">
+
{{ template "user/settings/fragments/sidebar" . }}
+
</div>
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
+
{{ template "notificationSettings" . }}
+
</div>
+
</section>
+
</div>
+
{{ end }}
+
+
{{ define "notificationSettings" }}
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Choose which notifications you want to receive when activity happens on your repositories and profile.
+
</p>
+
</div>
+
</div>
+
+
<form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6">
+
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Repository starred</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone stars your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New issues</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone creates an issue on your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Issue comments</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone comments on an issue you're involved with.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Issue closed</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When an issue on your repository is closed.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New pull requests</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone creates a pull request on your repository.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Pull request comments</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone comments on a pull request you're involved with.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Pull request merged</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When your pull request is merged.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">New followers</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>When someone follows you.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}>
+
</label>
+
</div>
+
+
<div class="flex items-center justify-between p-2">
+
<div class="flex items-center gap-2">
+
<div class="flex flex-col gap-1">
+
<span class="font-bold">Email notifications</span>
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>Receive notifications via email in addition to in-app notifications.</span>
+
</div>
+
</div>
+
</div>
+
<label class="flex items-center gap-2">
+
<input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}>
+
</label>
+
</div>
+
</div>
+
+
<div class="flex justify-end pt-2">
+
<button
+
type="submit"
+
class="btn-create flex items-center gap-2 group"
+
>
+
{{ i "save" "w-4 h-4" }}
+
save
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</div>
+
<div id="settings-notifications-success"></div>
+
+
<div id="settings-notifications-error" class="error"></div>
+
</form>
+
{{ end }}
+13 -4
appview/pages/templates/layouts/fragments/topbar.html
···
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
<div class="flex justify-between p-0 items-center">
<div id="left-items">
-
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline">
-
{{ template "fragments/logotypeSmall" }}
</a>
</div>
···
{{ define "newButton" }}
<details class="relative inline-block text-left nav-dropdown">
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
-
{{ i "plus" "w-4 h-4" }} new
</summary>
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
<a href="/repo/new" class="flex items-center gap-2">
···
class="cursor-pointer list-none flex items-center gap-1"
>
{{ $user := didOrHandle .Did .Handle }}
-
{{ template "user/fragments/picHandle" $user }}
</summary>
<div
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
···
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
<div class="flex justify-between p-0 items-center">
<div id="left-items">
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
+
alpha
+
</span>
</a>
</div>
···
{{ define "newButton" }}
<details class="relative inline-block text-left nav-dropdown">
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
</summary>
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
<a href="/repo/new" class="flex items-center gap-2">
···
class="cursor-pointer list-none flex items-center gap-1"
>
{{ $user := didOrHandle .Did .Handle }}
+
<img
+
src="{{ tinyAvatar $user }}"
+
alt=""
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
+
/>
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
</summary>
<div
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+2 -2
appview/pages/templates/strings/put.html
···
{{ define "content" }}
<div class="px-6 py-2 mb-4">
{{ if eq .Action "new" }}
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
-
<p class="">Store and share code snippets with ease.</p>
{{ else }}
<p class="text-xl font-bold dark:text-white">Edit string</p>
{{ end }}
···
{{ define "content" }}
<div class="px-6 py-2 mb-4">
{{ if eq .Action "new" }}
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
{{ else }}
<p class="text-xl font-bold dark:text-white">Edit string</p>
{{ end }}
+4 -1
appview/validator/validator.go
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/pages/markup"
"tangled.org/core/idresolver"
)
type Validator struct {
db *db.DB
sanitizer markup.Sanitizer
resolver *idresolver.Resolver
}
-
func New(db *db.DB, res *idresolver.Resolver) *Validator {
return &Validator{
db: db,
sanitizer: markup.NewSanitizer(),
resolver: res,
}
}
···
"tangled.org/core/appview/db"
"tangled.org/core/appview/pages/markup"
"tangled.org/core/idresolver"
+
"tangled.org/core/rbac"
)
type Validator struct {
db *db.DB
sanitizer markup.Sanitizer
resolver *idresolver.Resolver
+
enforcer *rbac.Enforcer
}
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
return &Validator{
db: db,
sanitizer: markup.NewSanitizer(),
resolver: res,
+
enforcer: enforcer,
}
}
+5 -4
appview/models/label.go
···
}
var ops []LabelOp
-
for _, o := range record.Add {
if o != nil {
op := mkOp(o)
-
op.Operation = LabelOperationAdd
ops = append(ops, op)
}
}
-
for _, o := range record.Delete {
if o != nil {
op := mkOp(o)
-
op.Operation = LabelOperationDel
ops = append(ops, op)
}
}
···
}
var ops []LabelOp
+
// deletes first, then additions
+
for _, o := range record.Delete {
if o != nil {
op := mkOp(o)
+
op.Operation = LabelOperationDel
ops = append(ops, op)
}
}
+
for _, o := range record.Add {
if o != nil {
op := mkOp(o)
+
op.Operation = LabelOperationAdd
ops = append(ops, op)
}
}
+1 -1
knotserver/xrpc/repo_blob.go
···
contents, err := gr.RawContent(treePath)
if err != nil {
-
x.Logger.Error("file content", "error", err.Error())
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("FileNotFound"),
xrpcerr.WithMessage("file not found at the specified path"),
···
contents, err := gr.RawContent(treePath)
if err != nil {
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
writeError(w, xrpcerr.NewXrpcError(
xrpcerr.WithTag("FileNotFound"),
xrpcerr.WithMessage("file not found at the specified path"),
+7 -5
types/repo.go
···
}
type RepoTreeResponse struct {
-
Ref string `json:"ref,omitempty"`
-
Parent string `json:"parent,omitempty"`
-
Description string `json:"description,omitempty"`
-
DotDot string `json:"dotdot,omitempty"`
-
Files []NiceTree `json:"files,omitempty"`
}
type TagReference struct {
···
}
type RepoTreeResponse struct {
+
Ref string `json:"ref,omitempty"`
+
Parent string `json:"parent,omitempty"`
+
Description string `json:"description,omitempty"`
+
DotDot string `json:"dotdot,omitempty"`
+
Files []NiceTree `json:"files,omitempty"`
+
ReadmeFileName string `json:"readme_filename,omitempty"`
+
Readme string `json:"readme_contents,omitempty"`
}
type TagReference struct {
+224
appview/pages/templates/brand/brand.html
···
···
+
{{ define "title" }}brand{{ end }}
+
+
{{ define "content" }}
+
<div class="grid grid-cols-10">
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
+
Assets and guidelines for using Tangled's logo and brand elements.
+
</p>
+
</header>
+
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
+
<div class="space-y-16">
+
+
<!-- Introduction Section -->
+
<section>
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
+
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
+
follow the below guidelines when using Dolly and the logotype.
+
</p>
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
+
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
+
</p>
+
</section>
+
+
<!-- Black Logotype Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
+
alt="Tangled logo - black version"
+
class="w-full max-w-sm mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
+
backgrounds and designs.
+
</p>
+
</div>
+
</section>
+
+
<!-- White Logotype Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="bg-black p-8 sm:p-16 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg"
+
alt="Tangled logo - white version"
+
class="w-full max-w-sm mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
This version features white text and elements, ideal for dark backgrounds
+
and inverted designs.
+
</p>
+
</div>
+
</section>
+
+
<!-- Mark Only Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Black mark on light background -->
+
<div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Dolly face - black version"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- White mark on dark background -->
+
<div class="bg-black p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Dolly face - white version"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
<strong class="font-semibold">Note</strong>: for situations where the background
+
is unknown, use the black version for ideal contrast in most environments.
+
</p>
+
</div>
+
</section>
+
+
<!-- Colored Backgrounds Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Pastel Green background -->
+
<div class="bg-green-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel green background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Blue background -->
+
<div class="bg-blue-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel blue background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Yellow background -->
+
<div class="bg-yellow-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel yellow background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Red background -->
+
<div class="bg-red-500 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
+
alt="Tangled logo on pastel red background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
White logo mark on colored backgrounds.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
The white logo mark provides contrast on colored backgrounds.
+
Perfect for more fun design contexts.
+
</p>
+
</div>
+
</section>
+
+
<!-- Black Logo on Pastel Backgrounds Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="grid grid-cols-2 gap-2">
+
<!-- Pastel Green background -->
+
<div class="bg-green-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel green background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Blue background -->
+
<div class="bg-blue-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel blue background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Yellow background -->
+
<div class="bg-yellow-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel yellow background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
<!-- Pastel Pink background -->
+
<div class="bg-pink-200 p-8 sm:p-12 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
+
alt="Tangled logo on pastel pink background"
+
class="w-full max-w-16 mx-auto" />
+
</div>
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
Dark logo mark on lighter, pastel backgrounds.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
The dark logo mark works beautifully on pastel backgrounds,
+
providing crisp contrast.
+
</p>
+
</div>
+
</section>
+
+
<!-- Recoloring Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded">
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
+
alt="Recolored Tangled logotype in gray/sand color"
+
class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
+
Custom coloring of the logotype is permitted.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
+
Recoloring the logotype is allowed as long as readability is maintained.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 text-sm">
+
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
+
</p>
+
</div>
+
</section>
+
+
<!-- Silhouette Section -->
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
+
<div class="order-2 lg:order-1">
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
+
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
+
alt="Dolly silhouette"
+
class="w-full max-w-32 mx-auto" />
+
</div>
+
</div>
+
<div class="order-1 lg:order-2">
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
+
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
+
<p class="text-gray-700 dark:text-gray-300">
+
The silhouette can be used where a subtle brand presence is needed,
+
or as a background element. Works on any background color with proper contrast.
+
For example, we use this as the site's favicon.
+
</p>
+
</div>
+
</section>
+
+
</div>
+
</main>
+
</div>
+
{{ end }}
+1 -1
appview/pages/templates/user/fragments/followCard.html
···
{{ define "user/fragments/followCard" }}
{{ $userIdent := resolve .UserDid }}
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
<div class="flex-shrink-0 max-h-full w-24 h-24">
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
···
{{ define "user/fragments/followCard" }}
{{ $userIdent := resolve .UserDid }}
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
<div class="flex-shrink-0 max-h-full w-24 h-24">
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
+4 -2
appview/config/config.go
···
}
type Cloudflare struct {
-
ApiToken string `env:"API_TOKEN"`
-
ZoneId string `env:"ZONE_ID"`
}
func (cfg RedisConfig) ToURL() string {
···
}
type Cloudflare struct {
+
ApiToken string `env:"API_TOKEN"`
+
ZoneId string `env:"ZONE_ID"`
+
TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"`
+
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
}
func (cfg RedisConfig) ToURL() string {
+13 -9
appview/db/email.go
···
return did, nil
}
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
-
if len(ems) == 0 {
return make(map[string]string), nil
}
···
verifiedFilter = 1
}
// Create placeholders for the IN clause
-
placeholders := make([]string, len(ems))
-
args := make([]any, len(ems)+1)
args[0] = verifiedFilter
-
for i, em := range ems {
-
placeholders[i] = "?"
-
args[i+1] = em
}
query := `
···
}
defer rows.Close()
-
assoc := make(map[string]string)
-
for rows.Next() {
var email, did string
if err := rows.Scan(&email, &did); err != nil {
···
return did, nil
}
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
+
if len(emails) == 0 {
return make(map[string]string), nil
}
···
verifiedFilter = 1
}
+
assoc := make(map[string]string)
+
// Create placeholders for the IN clause
+
placeholders := make([]string, 0, len(emails))
+
args := make([]any, 1, len(emails)+1)
args[0] = verifiedFilter
+
for _, email := range emails {
+
if strings.HasPrefix(email, "did:") {
+
assoc[email] = email
+
continue
+
}
+
placeholders = append(placeholders, "?")
+
args = append(args, email)
}
query := `
···
}
defer rows.Close()
for rows.Next() {
var email, did string
if err := rows.Scan(&email, &did); err != nil {
+140
appview/db/db.go
···
return err
})
return &DB{db}, nil
}
···
return err
})
+
// add generated at_uri column to pulls table
+
//
+
// this requires a full table recreation because stored columns
+
// cannot be added via alter
+
//
+
// disable foreign-keys for the next migration
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
+
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists pulls_new (
+
-- identifiers
+
id integer primary key autoincrement,
+
pull_id integer not null,
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
+
+
-- at identifiers
+
repo_at text not null,
+
owner_did text not null,
+
rkey text not null,
+
+
-- content
+
title text not null,
+
body text not null,
+
target_branch text not null,
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
+
+
-- source info
+
source_branch text,
+
source_repo_at text,
+
+
-- stacking
+
stack_id text,
+
change_id text,
+
parent_change_id text,
+
+
-- meta
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
unique(repo_at, pull_id),
+
unique(at_uri),
+
foreign key (repo_at) references repos(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data
+
_, err = tx.Exec(`
+
insert into pulls_new (
+
id, pull_id, repo_at, owner_did, rkey,
+
title, body, target_branch, state,
+
source_branch, source_repo_at,
+
stack_id, change_id, parent_change_id,
+
created
+
)
+
select
+
id, pull_id, repo_at, owner_did, rkey,
+
title, body, target_branch, state,
+
source_branch, source_repo_at,
+
stack_id, change_id, parent_change_id,
+
created
+
from pulls;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table pulls`)
+
if err != nil {
+
return err
+
}
+
+
// rename new table
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
+
return err
+
})
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
+
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
+
//
+
// this requires a full table recreation because stored columns
+
// cannot be added via alter
+
//
+
// disable foreign-keys for the next migration
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
+
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists pull_submissions_new (
+
-- identifiers
+
id integer primary key autoincrement,
+
pull_at text not null,
+
+
-- content, these are immutable, and require a resubmission to update
+
round_number integer not null default 0,
+
patch text,
+
source_rev text,
+
+
-- meta
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
unique(pull_at, round_number),
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data, constructing pull_at from pulls table
+
_, err = tx.Exec(`
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
+
select
+
ps.id,
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
+
ps.round_number,
+
ps.patch,
+
ps.created
+
from pull_submissions ps
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table pull_submissions`)
+
if err != nil {
+
return err
+
}
+
+
// rename new table
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
+
return err
+
})
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
+
return &DB{db}, nil
}
+5 -1
appview/issues/issues.go
···
return
}
-
labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
if err != nil {
log.Println("failed to fetch labels", err)
rp.pages.Error503(w)
···
return
}
+
labelDefs, err := db.GetLabelDefinitions(
+
rp.db,
+
db.FilterIn("at_uri", f.Repo.Labels),
+
db.FilterContains("scope", tangled.RepoIssueNSID),
+
)
if err != nil {
log.Println("failed to fetch labels", err)
rp.pages.Error503(w)
+44 -1
appview/models/pull.go
···
PullSource *PullSource
// optionally, populate this when querying for reverse mappings
-
Repo *Repo
}
func (p Pull) AsRecord() tangled.RepoPull {
···
return p.StackId != ""
}
func (s PullSubmission) IsFormatPatch() bool {
return patchutil.IsFormatPatch(s.Patch)
}
···
return patches
}
type Stack []*Pull
// position of this pull in the stack
···
PullSource *PullSource
// optionally, populate this when querying for reverse mappings
+
Labels LabelState
+
Repo *Repo
}
func (p Pull) AsRecord() tangled.RepoPull {
···
return p.StackId != ""
}
+
func (p *Pull) Participants() []string {
+
participantSet := make(map[string]struct{})
+
participants := []string{}
+
+
addParticipant := func(did string) {
+
if _, exists := participantSet[did]; !exists {
+
participantSet[did] = struct{}{}
+
participants = append(participants, did)
+
}
+
}
+
+
addParticipant(p.OwnerDid)
+
+
for _, s := range p.Submissions {
+
for _, sp := range s.Participants() {
+
addParticipant(sp)
+
}
+
}
+
+
return participants
+
}
+
func (s PullSubmission) IsFormatPatch() bool {
return patchutil.IsFormatPatch(s.Patch)
}
···
return patches
}
+
func (s *PullSubmission) Participants() []string {
+
participantSet := make(map[string]struct{})
+
participants := []string{}
+
+
addParticipant := func(did string) {
+
if _, exists := participantSet[did]; !exists {
+
participantSet[did] = struct{}{}
+
participants = append(participants, did)
+
}
+
}
+
+
addParticipant(s.PullAt.Authority().String())
+
+
for _, c := range s.Comments {
+
addParticipant(c.OwnerDid)
+
}
+
+
return participants
+
}
+
type Stack []*Pull
// position of this pull in the stack
+26
appview/pages/templates/repo/fragments/participants.html
···
···
+
{{ define "repo/fragments/participants" }}
+
{{ $all := . }}
+
{{ $ps := take $all 5 }}
+
<div class="px-6 md:px-0">
+
<div class="py-1 flex items-center text-sm">
+
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
+
</div>
+
<div class="flex items-center -space-x-3 mt-2">
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
+
{{ range $i, $p := $ps }}
+
<img
+
src="{{ tinyAvatar . }}"
+
alt=""
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
+
/>
+
{{ end }}
+
+
{{ if gt (len $all) 5 }}
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
+
+{{ sub (len $all) 5 }}
+
</span>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+1 -27
appview/pages/templates/repo/issues/issue.html
···
"Defs" $.LabelDefs
"Subject" $.Issue.AtUri
"State" $.Issue.Labels) }}
-
{{ template "issueParticipants" . }}
</div>
</div>
{{ end }}
···
</div>
{{ end }}
-
{{ define "issueParticipants" }}
-
{{ $all := .Issue.Participants }}
-
{{ $ps := take $all 5 }}
-
<div>
-
<div class="py-1 flex items-center text-sm">
-
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
-
</div>
-
<div class="flex items-center -space-x-3 mt-2">
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
-
{{ range $i, $p := $ps }}
-
<img
-
src="{{ tinyAvatar . }}"
-
alt=""
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
-
/>
-
{{ end }}
-
-
{{ if gt (len $all) 5 }}
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
-
+{{ sub (len $all) 5 }}
-
</span>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
{{ define "repoAfter" }}
<div class="flex flex-col gap-4 mt-4">
···
"Defs" $.LabelDefs
"Subject" $.Issue.AtUri
"State" $.Issue.Labels) }}
+
{{ template "repo/fragments/participants" $.Issue.Participants }}
</div>
</div>
{{ end }}
···
</div>
{{ end }}
{{ define "repoAfter" }}
<div class="flex flex-col gap-4 mt-4">
+30 -12
appview/pages/templates/repo/pulls/pull.html
···
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
{{ define "repoContent" }}
{{ template "repo/pulls/fragments/pullHeader" . }}
···
{{ with $item }}
<details {{ if eq $idx $lastIdx }}open{{ end }}>
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
-
<div class="flex flex-wrap gap-2 items-center">
<!-- round number -->
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
</div>
<!-- round summary -->
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
<span class="gap-1 flex items-center">
{{ $owner := resolve $.Pull.OwnerDid }}
{{ $re := "re" }}
···
<span class="hidden md:inline">diff</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</a>
-
{{ if not (eq .RoundNumber 0) }}
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
-
hx-boost="true"
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
-
<span class="hidden md:inline">interdiff</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</a>
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
{{ end }}
</div>
</summary>
···
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
{{ range $cidx, $c := .Comments }}
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
{{ if gt $cidx 0 }}
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
{{ end }}
···
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
{{ end }}
+
{{ define "repoContentLayout" }}
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
+
<div class="col-span-1 md:col-span-8">
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
+
{{ block "repoContent" . }}{{ end }}
+
</section>
+
{{ block "repoAfter" . }}{{ end }}
+
</div>
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
+
{{ template "repo/fragments/labelPanel"
+
(dict "RepoInfo" $.RepoInfo
+
"Defs" $.LabelDefs
+
"Subject" $.Pull.PullAt
+
"State" $.Pull.Labels) }}
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
+
</div>
+
</div>
+
{{ end }}
{{ define "repoContent" }}
{{ template "repo/pulls/fragments/pullHeader" . }}
···
{{ with $item }}
<details {{ if eq $idx $lastIdx }}open{{ end }}>
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
+
<div class="flex flex-wrap gap-2 items-stretch">
<!-- round number -->
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
</div>
<!-- round summary -->
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
<span class="gap-1 flex items-center">
{{ $owner := resolve $.Pull.OwnerDid }}
{{ $re := "re" }}
···
<span class="hidden md:inline">diff</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</a>
+
{{ if ne $idx 0 }}
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
+
hx-boost="true"
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
+
<span class="hidden md:inline">interdiff</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
{{ end }}
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
</div>
</summary>
···
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
{{ range $cidx, $c := .Comments }}
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
{{ if gt $cidx 0 }}
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
{{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
···
<span class="before:content-['ยท']"></span>
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
{{ end }}
</div>
</div>
{{ if .StackId }}
···
<span class="before:content-['ยท']"></span>
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
{{ end }}
+
+
{{ $state := .Labels }}
+
{{ range $k, $d := $.LabelDefs }}
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
+
{{ end }}
+
{{ end }}
</div>
</div>
{{ if .StackId }}
+35
appview/pulls/pulls.go
···
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
}
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
LoggedInUser: user,
RepoInfo: repoInfo,
···
OrderedReactionKinds: models.OrderedReactionKinds,
Reactions: reactionCountMap,
UserReacted: userReactions,
})
}
···
m[p.Sha] = p
}
s.pages.RepoPulls(w, pages.RepoPullsParams{
LoggedInUser: s.oauth.GetUser(r),
RepoInfo: f.RepoInfo(user),
Pulls: pulls,
FilteringBy: state,
Stacks: stacks,
Pipelines: m,
···
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
}
+
labelDefs, err := db.GetLabelDefinitions(
+
s.db,
+
db.FilterIn("at_uri", f.Repo.Labels),
+
db.FilterContains("scope", tangled.RepoPullNSID),
+
)
+
if err != nil {
+
log.Println("failed to fetch labels", err)
+
s.pages.Error503(w)
+
return
+
}
+
+
defs := make(map[string]*models.LabelDefinition)
+
for _, l := range labelDefs {
+
defs[l.AtUri().String()] = &l
+
}
+
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
LoggedInUser: user,
RepoInfo: repoInfo,
···
OrderedReactionKinds: models.OrderedReactionKinds,
Reactions: reactionCountMap,
UserReacted: userReactions,
+
+
LabelDefs: defs,
})
}
···
m[p.Sha] = p
}
+
labelDefs, err := db.GetLabelDefinitions(
+
s.db,
+
db.FilterIn("at_uri", f.Repo.Labels),
+
db.FilterContains("scope", tangled.RepoPullNSID),
+
)
+
if err != nil {
+
log.Println("failed to fetch labels", err)
+
s.pages.Error503(w)
+
return
+
}
+
+
defs := make(map[string]*models.LabelDefinition)
+
for _, l := range labelDefs {
+
defs[l.AtUri().String()] = &l
+
}
+
s.pages.RepoPulls(w, pages.RepoPullsParams{
LoggedInUser: s.oauth.GetUser(r),
RepoInfo: f.RepoInfo(user),
Pulls: pulls,
+
LabelDefs: defs,
FilteringBy: state,
Stacks: stacks,
Pipelines: m,
+8 -48
appview/notify/db/db.go
···
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
var err error
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt)))
if err != nil {
log.Printf("NewStar: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewStar: no repo found for %s", star.RepoAt)
-
return
-
}
-
repo := repos[0]
// don't notify yourself
if repo.Did == star.StarredByDid {
···
}
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssue: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewIssue: no repo found for %s", issue.RepoAt)
-
return
-
}
-
repo := repos[0]
if repo.Did == issue.Did {
return
···
}
issue := issues[0]
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssueComment: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt)
-
return
-
}
-
repo := repos[0]
recipients := make(map[string]bool)
···
}
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPull: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPull: no repo found for %s", pull.RepoAt)
-
return
-
}
-
repo := repos[0]
if repo.Did == pull.OwnerDid {
return
···
}
pull := pulls[0]
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt))
if err != nil {
log.Printf("NewPullComment: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPullComment: no repo found for %s", comment.RepoAt)
-
return
-
}
-
repo := repos[0]
recipients := make(map[string]bool)
···
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
// Get repo details
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssueClosed: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt)
-
return
-
}
-
repo := repos[0]
// Don't notify yourself
if repo.Did == issue.Did {
···
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
// Get repo details
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPullMerged: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt)
-
return
-
}
-
repo := repos[0]
// Don't notify yourself
if repo.Did == pull.OwnerDid {
···
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
// Get repo details
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPullClosed: failed to get repos: %v", err)
return
}
-
if len(repos) == 0 {
-
log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt)
-
return
-
}
-
repo := repos[0]
// Don't notify yourself
if repo.Did == pull.OwnerDid {
···
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
var err error
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
if err != nil {
log.Printf("NewStar: failed to get repos: %v", err)
return
}
// don't notify yourself
if repo.Did == star.StarredByDid {
···
}
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssue: failed to get repos: %v", err)
return
}
if repo.Did == issue.Did {
return
···
}
issue := issues[0]
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssueComment: failed to get repos: %v", err)
return
}
recipients := make(map[string]bool)
···
}
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPull: failed to get repos: %v", err)
return
}
if repo.Did == pull.OwnerDid {
return
···
}
pull := pulls[0]
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
if err != nil {
log.Printf("NewPullComment: failed to get repos: %v", err)
return
}
recipients := make(map[string]bool)
···
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
// Get repo details
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
if err != nil {
log.Printf("NewIssueClosed: failed to get repos: %v", err)
return
}
// Don't notify yourself
if repo.Did == issue.Did {
···
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
// Get repo details
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPullMerged: failed to get repos: %v", err)
return
}
// Don't notify yourself
if repo.Did == pull.OwnerDid {
···
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
// Get repo details
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
if err != nil {
log.Printf("NewPullClosed: failed to get repos: %v", err)
return
}
// Don't notify yourself
if repo.Did == pull.OwnerDid {
+18 -13
appview/db/notifications.go
···
import (
"context"
"database/sql"
"fmt"
"time"
"tangled.org/core/appview/models"
···
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
}
-
func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) {
-
recipientFilter := FilterEq("recipient_did", userDID)
-
readFilter := FilterEq("read", 0)
-
query := fmt.Sprintf(`
-
SELECT COUNT(*)
-
FROM notifications
-
WHERE %s AND %s
-
`, recipientFilter.Condition(), readFilter.Condition())
-
args := append(recipientFilter.Arg(), readFilter.Arg()...)
-
var count int
-
err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count)
-
if err != nil {
-
return 0, fmt.Errorf("failed to get unread count: %w", err)
}
return count, nil
···
import (
"context"
"database/sql"
+
"errors"
"fmt"
+
"strings"
"time"
"tangled.org/core/appview/models"
···
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
}
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
+
var count int64
+
err := e.QueryRow(query, args...).Scan(&count)
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
}
return count, nil
+29 -36
appview/notifications/notifications.go
···
package notifications
import (
"log"
"net/http"
"strconv"
···
r.Use(middleware.AuthMiddleware(n.oauth))
-
r.Get("/", n.notificationsPage)
r.Get("/count", n.getUnreadCount)
r.Post("/{id}/read", n.markRead)
···
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
userDid := n.oauth.GetDid(r)
-
limitStr := r.URL.Query().Get("limit")
-
offsetStr := r.URL.Query().Get("offset")
-
-
limit := 20 // default
-
if limitStr != "" {
-
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
-
limit = l
-
}
}
-
offset := 0 // default
-
if offsetStr != "" {
-
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
-
offset = o
-
}
}
-
page := pagination.Page{Limit: limit + 1, Offset: offset}
-
notifications, err := db.GetNotificationsWithEntities(n.db, page, db.FilterEq("recipient_did", userDid))
if err != nil {
log.Println("failed to get notifications:", err)
n.pages.Error500(w)
return
}
-
hasMore := len(notifications) > limit
-
if hasMore {
-
notifications = notifications[:limit]
-
}
-
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
if err != nil {
log.Println("failed to mark notifications as read:", err)
···
return
}
-
params := pages.NotificationsParams{
LoggedInUser: user,
Notifications: notifications,
UnreadCount: unreadCount,
-
HasMore: hasMore,
-
NextOffset: offset + limit,
-
Limit: limit,
-
}
-
-
err = n.pages.Notifications(w, params)
-
if err != nil {
-
log.Println("failed to load notifs:", err)
-
n.pages.Error500(w)
-
return
-
}
}
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
-
userDid := n.oauth.GetDid(r)
-
-
count, err := n.db.GetUnreadNotificationCount(r.Context(), userDid)
if err != nil {
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
return
···
package notifications
import (
+
"fmt"
"log"
"net/http"
"strconv"
···
r.Use(middleware.AuthMiddleware(n.oauth))
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
r.Get("/count", n.getUnreadCount)
r.Post("/{id}/read", n.markRead)
···
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
userDid := n.oauth.GetDid(r)
+
page, ok := r.Context().Value("page").(pagination.Page)
+
if !ok {
+
log.Println("failed to get page")
+
page = pagination.FirstPage()
}
+
total, err := db.CountNotifications(
+
n.db,
+
db.FilterEq("recipient_did", userDid),
+
)
+
if err != nil {
+
log.Println("failed to get total notifications:", err)
+
n.pages.Error500(w)
+
return
}
+
notifications, err := db.GetNotificationsWithEntities(
+
n.db,
+
page,
+
db.FilterEq("recipient_did", userDid),
+
)
if err != nil {
log.Println("failed to get notifications:", err)
n.pages.Error500(w)
return
}
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
if err != nil {
log.Println("failed to mark notifications as read:", err)
···
return
}
+
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
LoggedInUser: user,
Notifications: notifications,
UnreadCount: unreadCount,
+
Page: page,
+
Total: total,
+
}))
}
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
+
user := n.oauth.GetUser(r)
+
count, err := db.CountNotifications(
+
n.db,
+
db.FilterEq("recipient_did", user.Did),
+
db.FilterEq("read", 0),
+
)
if err != nil {
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
return
+48 -15
appview/pages/templates/notifications/list.html
···
</div>
</div>
-
{{if .Notifications}}
-
<div class="flex flex-col gap-2" id="notifications-list">
-
{{range .Notifications}}
-
{{template "notifications/fragments/item" .}}
-
{{end}}
-
</div>
-
{{else}}
-
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
-
<div class="text-center py-12">
-
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
-
{{ i "bell-off" "w-16 h-16" }}
-
</div>
-
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
-
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
</div>
</div>
-
{{end}}
{{ end }}
···
</div>
</div>
+
{{if .Notifications}}
+
<div class="flex flex-col gap-2" id="notifications-list">
+
{{range .Notifications}}
+
{{template "notifications/fragments/item" .}}
+
{{end}}
+
</div>
+
{{else}}
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<div class="text-center py-12">
+
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
+
{{ i "bell-off" "w-16 h-16" }}
</div>
+
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
+
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
</div>
+
</div>
+
{{end}}
+
+
{{ template "pagination" . }}
+
{{ end }}
+
+
{{ define "pagination" }}
+
<div class="flex justify-end mt-4 gap-2">
+
{{ if gt .Page.Offset 0 }}
+
{{ $prev := .Page.Previous }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
+
>
+
{{ i "chevron-left" "w-4 h-4" }}
+
previous
+
</a>
+
{{ else }}
+
<div></div>
+
{{ end }}
+
+
{{ $next := .Page.Next }}
+
{{ if lt $next.Offset .Total }}
+
{{ $next := .Page.Next }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
+
>
+
next
+
{{ i "chevron-right" "w-4 h-4" }}
+
</a>
+
{{ end }}
+
</div>
{{ end }}
+1 -1
appview/pagination/page.go
···
func FirstPage() Page {
return Page{
Offset: 0,
-
Limit: 10,
}
}
···
func FirstPage() Page {
return Page{
Offset: 0,
+
Limit: 30,
}
}
+27 -208
appview/pages/templates/notifications/fragments/item.html
···
{{define "notifications/fragments/item"}}
-
<div
-
class="
-
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
-
{{if not .Read}}bg-blue-50 dark:bg-blue-900/20 border border-blue-500 dark:border-sky-800{{end}}
-
flex gap-2 items-center
-
"
-
>
-
{{ template "notificationIcon" . }}
-
<div class="flex-1 w-full flex flex-col gap-1">
-
<span>{{ template "notificationHeader" . }}</span>
-
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
</div>
-
-
</div>
{{end}}
{{ define "notificationIcon" }}
···
{{ end }}
{{ end }}
-
{{define "issueNotification"}}
-
{{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="min-w-0 flex-1">
-
<!-- First line: icon + actor action -->
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
-
{{if eq .Type "issue_created"}}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "circle-dot" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "issue_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "message-circle" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "issue_closed"}}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "ban" "w-4 h-4" }}
-
</span>
-
{{end}}
-
{{template "user/fragments/picHandle" .ActorDid}}
-
{{if eq .Type "issue_created"}}
-
<span class="text-gray-500 dark:text-gray-400">opened issue</span>
-
{{else if eq .Type "issue_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">commented on issue</span>
-
{{else if eq .Type "issue_closed"}}
-
<span class="text-gray-500 dark:text-gray-400">closed issue</span>
-
{{end}}
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
-
<span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span>
-
<span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span>
-
<span>on</span>
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "pullNotification"}}
-
{{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="min-w-0 flex-1">
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
-
{{if eq .Type "pull_created"}}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "git-pull-request-create" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "pull_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "message-circle" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "pull_merged"}}
-
<span class="text-purple-600 dark:text-purple-500">
-
{{ i "git-merge" "w-4 h-4" }}
-
</span>
-
{{else if eq .Type "pull_closed"}}
-
<span class="text-red-600 dark:text-red-500">
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
-
</span>
-
{{end}}
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
-
{{if eq .Type "pull_created"}}
-
<span class="text-gray-500 dark:text-gray-400">opened pull request</span>
-
{{else if eq .Type "pull_commented"}}
-
<span class="text-gray-500 dark:text-gray-400">commented on pull request</span>
-
{{else if eq .Type "pull_merged"}}
-
<span class="text-gray-500 dark:text-gray-400">merged pull request</span>
-
{{else if eq .Type "pull_closed"}}
-
<span class="text-gray-500 dark:text-gray-400">closed pull request</span>
-
{{end}}
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
-
<span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span>
-
<span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span>
-
<span>on</span>
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "repoNotification"}}
-
{{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="flex items-center gap-2 min-w-0 flex-1">
-
<span class="text-yellow-500 dark:text-yellow-400">
-
{{ i "star" "w-4 h-4" }}
-
</span>
-
-
<div class="min-w-0 flex-1">
-
<!-- Single line for stars: actor action subject -->
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
-
<span class="text-gray-500 dark:text-gray-400">starred</span>
-
<span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "followNotification"}}
-
{{$url := printf "/%s" (resolve .ActorDid)}}
-
<a
-
href="{{$url}}"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="flex items-center gap-2 min-w-0 flex-1">
-
<span class="text-blue-600 dark:text-blue-400">
-
{{ i "user-plus" "w-4 h-4" }}
-
</span>
-
-
<div class="min-w-0 flex-1">
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
-
<span class="text-gray-500 dark:text-gray-400">followed you</span>
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
</div>
-
</div>
-
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
-
-
{{define "genericNotification"}}
-
<a
-
href="#"
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
-
>
-
<div class="flex items-center justify-between">
-
<div class="flex items-center gap-2 min-w-0 flex-1">
-
<span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}">
-
{{ i "bell" "w-4 h-4" }}
-
</span>
-
-
<div class="min-w-0 flex-1">
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
-
<span>New notification</span>
-
{{if not .Read}}
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
-
{{end}}
-
</div>
-
</div>
-
</div>
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
-
{{ template "repo/fragments/time" .Created }}
-
</div>
-
</div>
-
</a>
-
{{end}}
···
{{define "notifications/fragments/item"}}
+
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
+
<div
+
class="
+
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
+
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
+
flex gap-2 items-center
+
">
+
{{ template "notificationIcon" . }}
+
<div class="flex-1 w-full flex flex-col gap-1">
+
<span>{{ template "notificationHeader" . }}</span>
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
+
</div>
</div>
+
</a>
{{end}}
{{ define "notificationIcon" }}
···
{{ end }}
{{ end }}
+
{{ define "notificationUrl" }}
+
{{ $url := "" }}
+
{{ if eq .Type "repo_starred" }}
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
+
{{ else if .Issue }}
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
+
{{ else if .Pull }}
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
+
{{ else if eq .Type "followed" }}
+
{{$url = printf "/%s" (resolve .ActorDid)}}
+
{{ else }}
+
{{ end }}
+
{{ $url }}
+
{{ end }}
+1 -1
appview/pages/templates/layouts/fragments/footer.html
···
<a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a>
<a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a>
<a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a>
-
<a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a>
</div>
<div class="flex flex-col gap-1">
···
<a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a>
<a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a>
<a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a>
+
<a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a>
</div>
<div class="flex flex-col gap-1">
+2 -2
appview/db/pulls.go
···
// collect pull source for all pulls that need it
var sourceAts []syntax.ATURI
for _, p := range pulls {
-
if p.PullSource.RepoAt != nil {
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
}
}
···
sourceRepoMap[r.RepoAt()] = &r
}
for _, p := range pulls {
-
if p.PullSource.RepoAt != nil {
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
p.PullSource.Repo = sourceRepo
}
···
// collect pull source for all pulls that need it
var sourceAts []syntax.ATURI
for _, p := range pulls {
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
}
}
···
sourceRepoMap[r.RepoAt()] = &r
}
for _, p := range pulls {
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
p.PullSource.Repo = sourceRepo
}
+3
appview/pages/templates/layouts/base.html
···
<link rel="preconnect" href="https://avatar.tangled.sh" />
<link rel="preconnect" href="https://camo.tangled.sh" />
<!-- preload main font -->
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
···
<link rel="preconnect" href="https://avatar.tangled.sh" />
<link rel="preconnect" href="https://camo.tangled.sh" />
+
<!-- pwa manifest -->
+
<link rel="manifest" href="/pwa-manifest.json" />
+
<!-- preload main font -->
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
+1
appview/pages/templates/user/login.html
···
<meta property="og:url" content="https://tangled.org/login" />
<meta property="og:description" content="login to for tangled" />
<script src="/static/htmx.min.js"></script>
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>login &middot; tangled</title>
</head>
···
<meta property="og:url" content="https://tangled.org/login" />
<meta property="og:description" content="login to for tangled" />
<script src="/static/htmx.min.js"></script>
+
<link rel="manifest" href="/pwa-manifest.json" />
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>login &middot; tangled</title>
</head>
+1
appview/pages/templates/user/signup.html
···
<meta property="og:url" content="https://tangled.org/signup" />
<meta property="og:description" content="sign up for tangled" />
<script src="/static/htmx.min.js"></script>
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>sign up &middot; tangled</title>
···
<meta property="og:url" content="https://tangled.org/signup" />
<meta property="og:description" content="sign up for tangled" />
<script src="/static/htmx.min.js"></script>
+
<link rel="manifest" href="/pwa-manifest.json" />
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>sign up &middot; tangled</title>
+1
appview/state/router.go
···
router.Use(middleware.TryRefreshSession())
router.Get("/favicon.svg", s.Favicon)
router.Get("/favicon.ico", s.Favicon)
userRouter := s.UserRouter(&middleware)
standardRouter := s.StandardRouter(&middleware)
···
router.Use(middleware.TryRefreshSession())
router.Get("/favicon.svg", s.Favicon)
router.Get("/favicon.ico", s.Favicon)
+
router.Get("/pwa-manifest.json", s.PWAManifest)
userRouter := s.UserRouter(&middleware)
standardRouter := s.StandardRouter(&middleware)
+14 -1
appview/state/knotstream.go
···
})
}
-
return db.InsertRepoLanguages(d, langs)
}
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
})
}
+
tx, err := d.Begin()
+
if err != nil {
+
return err
+
}
+
defer tx.Rollback()
+
+
// update appview's cache
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
+
if err != nil {
+
fmt.Printf("failed; %s\n", err)
+
// non-fatal
+
}
+
+
return tx.Commit()
}
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {