-15
cmd/keyfetch/format.go
-15
cmd/keyfetch/format.go
···
1
-
package main
2
-
3
-
import (
4
-
"fmt"
5
-
)
6
-
7
-
func formatKeyData(repoguardPath, gitDir, logPath, endpoint string, data []map[string]interface{}) string {
8
-
var result string
9
-
for _, entry := range data {
10
-
result += fmt.Sprintf(
11
-
`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",
12
-
repoguardPath, gitDir, entry["did"], logPath, endpoint, entry["key"])
13
-
}
14
-
return result
15
-
}
-46
cmd/keyfetch/main.go
-46
cmd/keyfetch/main.go
···
1
-
// This program must be configured to run as the sshd AuthorizedKeysCommand.
2
-
// The format looks something like this:
3
-
// Match User git
4
-
// AuthorizedKeysCommand /keyfetch -internal-api http://localhost:5444 -repoguard-path /home/git/repoguard
5
-
// AuthorizedKeysCommandUser nobody
6
-
//
7
-
// The command and its parent directories must be owned by root and set to 0755. Hence, the ideal location for this is
8
-
// somewhere already owned by root so you don't have to mess with directory perms.
9
-
10
-
package main
11
-
12
-
import (
13
-
"encoding/json"
14
-
"flag"
15
-
"fmt"
16
-
"io"
17
-
"log"
18
-
"net/http"
19
-
)
20
-
21
-
func main() {
22
-
endpoint := flag.String("internal-api", "http://localhost:5444", "Internal API endpoint")
23
-
repoguardPath := flag.String("repoguard-path", "/home/git/repoguard", "Path to the repoguard binary")
24
-
gitDir := flag.String("git-dir", "/home/git", "Path to the git directory")
25
-
logPath := flag.String("log-path", "/home/git/log", "Path to log file")
26
-
flag.Parse()
27
-
28
-
resp, err := http.Get(*endpoint + "/keys")
29
-
if err != nil {
30
-
log.Fatalf("error fetching keys: %v", err)
31
-
}
32
-
defer resp.Body.Close()
33
-
34
-
body, err := io.ReadAll(resp.Body)
35
-
if err != nil {
36
-
log.Fatalf("error reading response body: %v", err)
37
-
}
38
-
39
-
var data []map[string]interface{}
40
-
err = json.Unmarshal(body, &data)
41
-
if err != nil {
42
-
log.Fatalf("error unmarshalling response body: %v", err)
43
-
}
44
-
45
-
fmt.Print(formatKeyData(*repoguardPath, *gitDir, *logPath, *endpoint, data))
46
-
}
-207
cmd/repoguard/main.go
-207
cmd/repoguard/main.go
···
1
-
package main
2
-
3
-
import (
4
-
"context"
5
-
"flag"
6
-
"fmt"
7
-
"log"
8
-
"net/http"
9
-
"net/url"
10
-
"os"
11
-
"os/exec"
12
-
"strings"
13
-
"time"
14
-
15
-
securejoin "github.com/cyphar/filepath-securejoin"
16
-
"tangled.sh/tangled.sh/core/appview/idresolver"
17
-
)
18
-
19
-
var (
20
-
logger *log.Logger
21
-
logFile *os.File
22
-
clientIP string
23
-
24
-
// Command line flags
25
-
incomingUser = flag.String("user", "", "Allowed git user")
26
-
baseDirFlag = flag.String("base-dir", "/home/git", "Base directory for git repositories")
27
-
logPathFlag = flag.String("log-path", "/var/log/git-wrapper.log", "Path to log file")
28
-
endpoint = flag.String("internal-api", "http://localhost:5444", "Internal API endpoint")
29
-
)
30
-
31
-
func main() {
32
-
flag.Parse()
33
-
34
-
defer cleanup()
35
-
initLogger()
36
-
37
-
// Get client IP from SSH environment
38
-
if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" {
39
-
parts := strings.Fields(connInfo)
40
-
if len(parts) > 0 {
41
-
clientIP = parts[0]
42
-
}
43
-
}
44
-
45
-
if *incomingUser == "" {
46
-
exitWithLog("access denied: no user specified")
47
-
}
48
-
49
-
sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND")
50
-
51
-
logEvent("Connection attempt", map[string]interface{}{
52
-
"user": *incomingUser,
53
-
"command": sshCommand,
54
-
"client": clientIP,
55
-
})
56
-
57
-
if sshCommand == "" {
58
-
exitWithLog("access denied: we don't serve interactive shells :)")
59
-
}
60
-
61
-
cmdParts := strings.Fields(sshCommand)
62
-
if len(cmdParts) < 2 {
63
-
exitWithLog("invalid command format")
64
-
}
65
-
66
-
gitCommand := cmdParts[0]
67
-
68
-
// did:foo/repo-name or
69
-
// handle/repo-name or
70
-
// any of the above with a leading slash (/)
71
-
72
-
components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
73
-
logEvent("Command components", map[string]interface{}{
74
-
"components": components,
75
-
})
76
-
if len(components) != 2 {
77
-
exitWithLog("invalid repo format, needs <user>/<repo> or /<user>/<repo>")
78
-
}
79
-
80
-
didOrHandle := components[0]
81
-
did := resolveToDid(didOrHandle)
82
-
repoName := components[1]
83
-
qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
84
-
85
-
validCommands := map[string]bool{
86
-
"git-receive-pack": true,
87
-
"git-upload-pack": true,
88
-
"git-upload-archive": true,
89
-
}
90
-
if !validCommands[gitCommand] {
91
-
exitWithLog("access denied: invalid git command")
92
-
}
93
-
94
-
if gitCommand != "git-upload-pack" {
95
-
if !isPushPermitted(*incomingUser, qualifiedRepoName) {
96
-
logEvent("all infos", map[string]interface{}{
97
-
"did": *incomingUser,
98
-
"reponame": qualifiedRepoName,
99
-
})
100
-
exitWithLog("access denied: user not allowed")
101
-
}
102
-
}
103
-
104
-
fullPath, _ := securejoin.SecureJoin(*baseDirFlag, qualifiedRepoName)
105
-
106
-
logEvent("Processing command", map[string]interface{}{
107
-
"user": *incomingUser,
108
-
"command": gitCommand,
109
-
"repo": repoName,
110
-
"fullPath": fullPath,
111
-
"client": clientIP,
112
-
})
113
-
114
-
if gitCommand == "git-upload-pack" {
115
-
fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!")
116
-
} else {
117
-
fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!")
118
-
}
119
-
120
-
cmd := exec.Command(gitCommand, fullPath)
121
-
cmd.Stdout = os.Stdout
122
-
cmd.Stderr = os.Stderr
123
-
cmd.Stdin = os.Stdin
124
-
125
-
if err := cmd.Run(); err != nil {
126
-
exitWithLog(fmt.Sprintf("command failed: %v", err))
127
-
}
128
-
129
-
logEvent("Command completed", map[string]interface{}{
130
-
"user": *incomingUser,
131
-
"command": gitCommand,
132
-
"repo": repoName,
133
-
"success": true,
134
-
})
135
-
}
136
-
137
-
func resolveToDid(didOrHandle string) string {
138
-
resolver := idresolver.DefaultResolver()
139
-
ident, err := resolver.ResolveIdent(context.Background(), didOrHandle)
140
-
if err != nil {
141
-
exitWithLog(fmt.Sprintf("error resolving handle: %v", err))
142
-
}
143
-
144
-
// did:plc:foobarbaz/repo
145
-
return ident.DID.String()
146
-
}
147
-
148
-
func initLogger() {
149
-
var err error
150
-
logFile, err = os.OpenFile(*logPathFlag, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
151
-
if err != nil {
152
-
fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err)
153
-
os.Exit(1)
154
-
}
155
-
156
-
logger = log.New(logFile, "", 0)
157
-
}
158
-
159
-
func logEvent(event string, fields map[string]interface{}) {
160
-
entry := fmt.Sprintf(
161
-
"timestamp=%q event=%q",
162
-
time.Now().Format(time.RFC3339),
163
-
event,
164
-
)
165
-
166
-
for k, v := range fields {
167
-
entry += fmt.Sprintf(" %s=%q", k, v)
168
-
}
169
-
170
-
logger.Println(entry)
171
-
}
172
-
173
-
func exitWithLog(message string) {
174
-
logEvent("Access denied", map[string]interface{}{
175
-
"error": message,
176
-
})
177
-
logFile.Sync()
178
-
fmt.Fprintf(os.Stderr, "error: %s\n", message)
179
-
os.Exit(1)
180
-
}
181
-
182
-
func cleanup() {
183
-
if logFile != nil {
184
-
logFile.Sync()
185
-
logFile.Close()
186
-
}
187
-
}
188
-
189
-
func isPushPermitted(user, qualifiedRepoName string) bool {
190
-
u, _ := url.Parse(*endpoint + "/push-allowed")
191
-
q := u.Query()
192
-
q.Add("user", user)
193
-
q.Add("repo", qualifiedRepoName)
194
-
u.RawQuery = q.Encode()
195
-
196
-
req, err := http.Get(u.String())
197
-
if err != nil {
198
-
exitWithLog(fmt.Sprintf("error verifying permissions: %v", err))
199
-
}
200
-
201
-
logEvent("url", map[string]interface{}{
202
-
"url": u.String(),
203
-
"status": req.Status,
204
-
})
205
-
206
-
return req.StatusCode == http.StatusNoContent
207
-
}
-71
cmd/knotserver/main.go
-71
cmd/knotserver/main.go
···
1
-
package main
2
-
3
-
import (
4
-
"context"
5
-
"net/http"
6
-
7
-
"tangled.sh/tangled.sh/core/api/tangled"
8
-
"tangled.sh/tangled.sh/core/jetstream"
9
-
"tangled.sh/tangled.sh/core/knotserver"
10
-
"tangled.sh/tangled.sh/core/knotserver/config"
11
-
"tangled.sh/tangled.sh/core/knotserver/db"
12
-
"tangled.sh/tangled.sh/core/log"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
15
-
_ "net/http/pprof"
16
-
)
17
-
18
-
func main() {
19
-
ctx := context.Background()
20
-
// ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
21
-
// defer stop()
22
-
23
-
l := log.New("knotserver")
24
-
25
-
c, err := config.Load(ctx)
26
-
if err != nil {
27
-
l.Error("failed to load config", "error", err)
28
-
return
29
-
}
30
-
31
-
if c.Server.Dev {
32
-
l.Info("running in dev mode, signature verification is disabled")
33
-
}
34
-
35
-
db, err := db.Setup(c.Server.DBPath)
36
-
if err != nil {
37
-
l.Error("failed to setup db", "error", err)
38
-
return
39
-
}
40
-
41
-
e, err := rbac.NewEnforcer(c.Server.DBPath)
42
-
if err != nil {
43
-
l.Error("failed to setup rbac enforcer", "error", err)
44
-
return
45
-
}
46
-
47
-
e.E.EnableAutoSave(true)
48
-
49
-
jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{
50
-
tangled.PublicKeyNSID,
51
-
tangled.KnotMemberNSID,
52
-
}, nil, l, db, true)
53
-
if err != nil {
54
-
l.Error("failed to setup jetstream", "error", err)
55
-
}
56
-
57
-
mux, err := knotserver.Setup(ctx, c, db, e, jc, l)
58
-
if err != nil {
59
-
l.Error("failed to setup server", "error", err)
60
-
return
61
-
}
62
-
imux := knotserver.Internal(ctx, db, e)
63
-
64
-
l.Info("starting internal server", "address", c.Server.InternalListenAddr)
65
-
go http.ListenAndServe(c.Server.InternalListenAddr, imux)
66
-
67
-
l.Info("starting main server", "address", c.Server.ListenAddr)
68
-
l.Error("server error", "error", http.ListenAndServe(c.Server.ListenAddr, mux))
69
-
70
-
return
71
-
}
+1
-1
systemd/knotserver.service
+1
-1
systemd/knotserver.service
+43
knotserver/notifier/notifier.go
+43
knotserver/notifier/notifier.go
···
1
+
package notifier
2
+
3
+
import (
4
+
"sync"
5
+
)
6
+
7
+
type Notifier struct {
8
+
subscribers map[chan struct{}]struct{}
9
+
mu sync.Mutex
10
+
}
11
+
12
+
func New() Notifier {
13
+
return Notifier{
14
+
subscribers: make(map[chan struct{}]struct{}),
15
+
}
16
+
}
17
+
18
+
func (n *Notifier) Subscribe() chan struct{} {
19
+
ch := make(chan struct{}, 1)
20
+
n.mu.Lock()
21
+
n.subscribers[ch] = struct{}{}
22
+
n.mu.Unlock()
23
+
return ch
24
+
}
25
+
26
+
func (n *Notifier) Unsubscribe(ch chan struct{}) {
27
+
n.mu.Lock()
28
+
delete(n.subscribers, ch)
29
+
close(ch)
30
+
n.mu.Unlock()
31
+
}
32
+
33
+
func (n *Notifier) NotifyAll() {
34
+
n.mu.Lock()
35
+
for ch := range n.subscribers {
36
+
select {
37
+
case ch <- struct{}{}:
38
+
default:
39
+
// avoid blocking if channel is full
40
+
}
41
+
}
42
+
n.mu.Unlock()
43
+
}
-33
docker/docker-compose.yml
-33
docker/docker-compose.yml
···
1
-
services:
2
-
knot:
3
-
build:
4
-
context: ..
5
-
dockerfile: docker/Dockerfile
6
-
environment:
7
-
KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME}
8
-
KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET}
9
-
KNOT_SERVER_DB_PATH: "/app/knotserver.db"
10
-
KNOT_REPO_SCAN_PATH: "/home/git/repositories"
11
-
volumes:
12
-
- "./keys:/etc/ssh/keys"
13
-
- "./repositories:/home/git/repositories"
14
-
- "./server:/app"
15
-
ports:
16
-
- "2222:22"
17
-
frontend:
18
-
image: caddy:2-alpine
19
-
command: >
20
-
caddy
21
-
reverse-proxy
22
-
--from ${KNOT_SERVER_HOSTNAME}
23
-
--to knot:5555
24
-
depends_on:
25
-
- knot
26
-
ports:
27
-
- "443:443"
28
-
- "443:443/udp"
29
-
volumes:
30
-
- caddy_data:/data
31
-
restart: always
32
-
volumes:
33
-
caddy_data:
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
···
1
-
oneshot
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
···
1
-
/etc/s6-overlay/scripts/create-sshd-host-keys
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
···
1
-
longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/base
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
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
-3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
-1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
···
1
-
longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd
-21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
-21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
···
1
-
#!/usr/bin/execlineb -P
2
-
3
-
foreground {
4
-
if -n { test -d /etc/ssh/keys }
5
-
mkdir /etc/ssh/keys
6
-
}
7
-
8
-
foreground {
9
-
if -n { test -f /etc/ssh/keys/ssh_host_rsa_key }
10
-
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_rsa_key -q -N ""
11
-
}
12
-
13
-
foreground {
14
-
if -n { test -f /etc/ssh/keys/ssh_host_ecdsa_key }
15
-
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ecdsa_key -q -N ""
16
-
}
17
-
18
-
foreground {
19
-
if -n { test -f /etc/ssh/keys/ssh_host_ed25519_key }
20
-
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ed25519_key -q -N ""
21
-
}
+14
appview/cache/cache.go
+14
appview/cache/cache.go
+18
nix/pkgs/sqlite-lib.nix
+18
nix/pkgs/sqlite-lib.nix
···
1
+
{
2
+
gcc,
3
+
stdenv,
4
+
sqlite-lib-src,
5
+
}:
6
+
stdenv.mkDerivation {
7
+
name = "sqlite-lib";
8
+
src = sqlite-lib-src;
9
+
nativeBuildInputs = [gcc];
10
+
buildPhase = ''
11
+
gcc -c sqlite3.c
12
+
ar rcs libsqlite3.a sqlite3.o
13
+
ranlib libsqlite3.a
14
+
mkdir -p $out/include $out/lib
15
+
cp *.h $out/include
16
+
cp libsqlite3.a $out/lib
17
+
'';
18
+
}
+20
nix/pkgs/knot.nix
+20
nix/pkgs/knot.nix
···
1
+
{
2
+
knot-unwrapped,
3
+
makeWrapper,
4
+
git,
5
+
}:
6
+
knot-unwrapped.overrideAttrs (after: before: {
7
+
nativeBuildInputs = (before.nativeBuildInputs or []) ++ [makeWrapper];
8
+
9
+
installPhase = ''
10
+
runHook preInstall
11
+
12
+
mkdir -p $out/bin
13
+
cp $GOPATH/bin/knot $out/bin/knot
14
+
15
+
wrapProgram $out/bin/knot \
16
+
--prefix PATH : ${git}/bin
17
+
18
+
runHook postInstall
19
+
'';
20
+
})
-70
knotserver/db/oplog.go
-70
knotserver/db/oplog.go
···
1
-
package db
2
-
3
-
import (
4
-
"fmt"
5
-
6
-
"tangled.sh/tangled.sh/core/knotserver/notifier"
7
-
)
8
-
9
-
type Op struct {
10
-
Tid string // time based ID, easy to enumerate & monotonic
11
-
Did string // did of pusher
12
-
Repo string // <did/repo> fully qualified repo
13
-
OldSha string // old sha of reference being updated
14
-
NewSha string // new sha of reference being updated
15
-
Ref string // the reference being updated
16
-
}
17
-
18
-
func (d *DB) InsertOp(op Op, notifier *notifier.Notifier) error {
19
-
_, err := d.db.Exec(
20
-
`insert into oplog (tid, did, repo, old_sha, new_sha, ref) values (?, ?, ?, ?, ?, ?)`,
21
-
op.Tid,
22
-
op.Did,
23
-
op.Repo,
24
-
op.OldSha,
25
-
op.NewSha,
26
-
op.Ref,
27
-
)
28
-
if err != nil {
29
-
return err
30
-
}
31
-
32
-
notifier.NotifyAll()
33
-
return nil
34
-
}
35
-
36
-
func (d *DB) GetOps(cursor string) ([]Op, error) {
37
-
whereClause := ""
38
-
args := []any{}
39
-
if cursor != "" {
40
-
whereClause = "where tid > ?"
41
-
args = append(args, cursor)
42
-
}
43
-
44
-
query := fmt.Sprintf(`
45
-
select tid, did, repo, old_sha, new_sha, ref
46
-
from oplog
47
-
%s
48
-
order by tid asc
49
-
limit 100
50
-
`, whereClause)
51
-
52
-
rows, err := d.db.Query(query, args...)
53
-
if err != nil {
54
-
return nil, err
55
-
}
56
-
defer rows.Close()
57
-
58
-
var ops []Op
59
-
for rows.Next() {
60
-
var op Op
61
-
rows.Scan(&op.Tid, &op.Did, &op.Repo, &op.OldSha, &op.NewSha, &op.Ref)
62
-
ops = append(ops, op)
63
-
}
64
-
65
-
if err := rows.Err(); err != nil {
66
-
return nil, err
67
-
}
68
-
69
-
return ops, nil
70
-
}
+9
spindle/tid.go
+9
spindle/tid.go
knotserver/notifier/notifier.go
notifier/notifier.go
knotserver/notifier/notifier.go
notifier/notifier.go
+23
knotclient/cursor/memory.go
+23
knotclient/cursor/memory.go
···
1
+
package cursor
2
+
3
+
import (
4
+
"sync"
5
+
)
6
+
7
+
type MemoryStore struct {
8
+
store sync.Map
9
+
}
10
+
11
+
func (m *MemoryStore) Set(knot string, cursor int64) {
12
+
m.store.Store(knot, cursor)
13
+
}
14
+
15
+
func (m *MemoryStore) Get(knot string) (cursor int64) {
16
+
if result, ok := m.store.Load(knot); ok {
17
+
if val, ok := result.(int64); ok {
18
+
return val
19
+
}
20
+
}
21
+
22
+
return 0
23
+
}
+43
knotclient/cursor/redis.go
+43
knotclient/cursor/redis.go
···
1
+
package cursor
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"strconv"
7
+
8
+
"tangled.sh/tangled.sh/core/appview/cache"
9
+
)
10
+
11
+
const (
12
+
cursorKey = "cursor:%s"
13
+
)
14
+
15
+
type RedisStore struct {
16
+
rdb *cache.Cache
17
+
}
18
+
19
+
func NewRedisCursorStore(cache *cache.Cache) RedisStore {
20
+
return RedisStore{
21
+
rdb: cache,
22
+
}
23
+
}
24
+
25
+
func (r *RedisStore) Set(knot string, cursor int64) {
26
+
key := fmt.Sprintf(cursorKey, knot)
27
+
r.rdb.Set(context.Background(), key, cursor, 0)
28
+
}
29
+
30
+
func (r *RedisStore) Get(knot string) (cursor int64) {
31
+
key := fmt.Sprintf(cursorKey, knot)
32
+
val, err := r.rdb.Get(context.Background(), key).Result()
33
+
if err != nil {
34
+
return 0
35
+
}
36
+
cursor, err = strconv.ParseInt(val, 10, 64)
37
+
if err != nil {
38
+
// TODO: log here
39
+
return 0
40
+
}
41
+
42
+
return cursor
43
+
}
+6
knotclient/cursor/store.go
+6
knotclient/cursor/store.go
+83
knotclient/cursor/sqlite.go
+83
knotclient/cursor/sqlite.go
···
1
+
package cursor
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
7
+
_ "github.com/mattn/go-sqlite3"
8
+
)
9
+
10
+
type SqliteStore struct {
11
+
db *sql.DB
12
+
tableName string
13
+
}
14
+
15
+
type SqliteStoreOpt func(*SqliteStore)
16
+
17
+
func WithTableName(name string) SqliteStoreOpt {
18
+
return func(s *SqliteStore) {
19
+
s.tableName = name
20
+
}
21
+
}
22
+
23
+
func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) {
24
+
db, err := sql.Open("sqlite3", dbPath)
25
+
if err != nil {
26
+
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
27
+
}
28
+
29
+
store := &SqliteStore{
30
+
db: db,
31
+
tableName: "cursors",
32
+
}
33
+
34
+
for _, o := range opts {
35
+
o(store)
36
+
}
37
+
38
+
if err := store.init(); err != nil {
39
+
return nil, err
40
+
}
41
+
42
+
return store, nil
43
+
}
44
+
45
+
func (s *SqliteStore) init() error {
46
+
createTable := fmt.Sprintf(`
47
+
create table if not exists %s (
48
+
knot text primary key,
49
+
cursor text
50
+
);`, s.tableName)
51
+
_, err := s.db.Exec(createTable)
52
+
return err
53
+
}
54
+
55
+
func (s *SqliteStore) Set(knot string, cursor int64) {
56
+
query := fmt.Sprintf(`
57
+
insert into %s (knot, cursor)
58
+
values (?, ?)
59
+
on conflict(knot) do update set cursor=excluded.cursor;
60
+
`, s.tableName)
61
+
62
+
_, err := s.db.Exec(query, knot, cursor)
63
+
64
+
if err != nil {
65
+
// TODO: log here
66
+
}
67
+
}
68
+
69
+
func (s *SqliteStore) Get(knot string) (cursor int64) {
70
+
query := fmt.Sprintf(`
71
+
select cursor from %s where knot = ?;
72
+
`, s.tableName)
73
+
err := s.db.QueryRow(query, knot).Scan(&cursor)
74
+
75
+
if err != nil {
76
+
if err != sql.ErrNoRows {
77
+
// TODO: log here
78
+
}
79
+
return 0
80
+
}
81
+
82
+
return cursor
83
+
}
+1
-1
spindle/tid.go
tid/tid.go
+1
-1
spindle/tid.go
tid/tid.go
+44
spindle/db/known_dids.go
+44
spindle/db/known_dids.go
···
1
+
package db
2
+
3
+
func (d *DB) AddDid(did string) error {
4
+
_, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did)
5
+
return err
6
+
}
7
+
8
+
func (d *DB) RemoveDid(did string) error {
9
+
_, err := d.Exec(`delete from known_dids where did = ?`, did)
10
+
return err
11
+
}
12
+
13
+
func (d *DB) GetAllDids() ([]string, error) {
14
+
var dids []string
15
+
16
+
rows, err := d.Query(`select did from known_dids`)
17
+
if err != nil {
18
+
return nil, err
19
+
}
20
+
defer rows.Close()
21
+
22
+
for rows.Next() {
23
+
var did string
24
+
if err := rows.Scan(&did); err != nil {
25
+
return nil, err
26
+
}
27
+
dids = append(dids, did)
28
+
}
29
+
30
+
if err := rows.Err(); err != nil {
31
+
return nil, err
32
+
}
33
+
34
+
return dids, nil
35
+
}
36
+
37
+
func (d *DB) HasKnownDids() bool {
38
+
var count int
39
+
err := d.QueryRow(`select count(*) from known_dids`).Scan(&count)
40
+
if err != nil {
41
+
return false
42
+
}
43
+
return count > 0
44
+
}
+4
lexicons/repo.json
+4
lexicons/repo.json
+22
api/tangled/tangledspindle.go
+22
api/tangled/tangledspindle.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.spindle
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
SpindleNSID = "sh.tangled.spindle"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.spindle", &Spindle{})
17
+
} //
18
+
// RECORDTYPE: Spindle
19
+
type Spindle struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.spindle" cborgen:"$type,const=sh.tangled.spindle"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
}
+25
lexicons/spindle.json
+25
lexicons/spindle.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.spindle",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"createdAt"
14
+
],
15
+
"properties": {
16
+
"createdAt": {
17
+
"type": "string",
18
+
"format": "datetime"
19
+
}
20
+
}
21
+
}
22
+
}
23
+
}
24
+
}
25
+
+6
knotclient/events.go
+6
knotclient/events.go
···
150
150
}
151
151
152
152
func (c *EventConsumer) AddSource(ctx context.Context, s EventSource) {
153
+
// we are already listening to this source
154
+
if _, ok := c.cfg.Sources[s]; ok {
155
+
c.logger.Info("source already present", "source", s)
156
+
return
157
+
}
158
+
153
159
c.cfgMu.Lock()
154
160
c.cfg.Sources[s] = struct{}{}
155
161
c.wg.Add(1)
knotclient/cursor/memory.go
eventconsumer/cursor/memory.go
knotclient/cursor/memory.go
eventconsumer/cursor/memory.go
knotclient/cursor/store.go
eventconsumer/cursor/store.go
knotclient/cursor/store.go
eventconsumer/cursor/store.go
+39
eventconsumer/knot.go
+39
eventconsumer/knot.go
···
1
+
package eventconsumer
2
+
3
+
import (
4
+
"fmt"
5
+
"net/url"
6
+
)
7
+
8
+
type KnotSource struct {
9
+
Knot string
10
+
}
11
+
12
+
func (k KnotSource) Key() string {
13
+
return k.Knot
14
+
}
15
+
16
+
func (k KnotSource) Url(cursor int64, dev bool) (*url.URL, error) {
17
+
scheme := "wss"
18
+
if dev {
19
+
scheme = "ws"
20
+
}
21
+
22
+
u, err := url.Parse(scheme + "://" + k.Knot + "/events")
23
+
if err != nil {
24
+
return nil, err
25
+
}
26
+
27
+
if cursor != 0 {
28
+
query := url.Values{}
29
+
query.Add("cursor", fmt.Sprintf("%d", cursor))
30
+
u.RawQuery = query.Encode()
31
+
}
32
+
return u, nil
33
+
}
34
+
35
+
func NewKnotSource(knot string) KnotSource {
36
+
return KnotSource{
37
+
Knot: knot,
38
+
}
39
+
}
+39
eventconsumer/spindle.go
+39
eventconsumer/spindle.go
···
1
+
package eventconsumer
2
+
3
+
import (
4
+
"fmt"
5
+
"net/url"
6
+
)
7
+
8
+
type SpindleSource struct {
9
+
Spindle string
10
+
}
11
+
12
+
func (s SpindleSource) Key() string {
13
+
return s.Spindle
14
+
}
15
+
16
+
func (s SpindleSource) Url(cursor int64, dev bool) (*url.URL, error) {
17
+
scheme := "wss"
18
+
if dev {
19
+
scheme = "ws"
20
+
}
21
+
22
+
u, err := url.Parse(scheme + "://" + s.Spindle + "/events")
23
+
if err != nil {
24
+
return nil, err
25
+
}
26
+
27
+
if cursor != 0 {
28
+
query := url.Values{}
29
+
query.Add("cursor", fmt.Sprintf("%d", cursor))
30
+
u.RawQuery = query.Encode()
31
+
}
32
+
return u, nil
33
+
}
34
+
35
+
func NewSpindleSource(spindle string) SpindleSource {
36
+
return SpindleSource{
37
+
Spindle: spindle,
38
+
}
39
+
}
-119
appview/pages/templates/repo/fragments/pipelineStatusSymbol.html
-119
appview/pages/templates/repo/fragments/pipelineStatusSymbol.html
···
1
-
{{ define "repo/fragments/pipelineStatusSymbol" }}
2
-
<div class="group relative inline-block">
3
-
{{ block "icon" $ }} {{ end }}
4
-
{{ block "tooltip" $ }} {{ end }}
5
-
</div>
6
-
{{ end }}
7
-
8
-
{{ define "icon" }}
9
-
<div class="cursor-pointer">
10
-
{{ $c := .Counts }}
11
-
{{ $statuses := .Statuses }}
12
-
{{ $total := len $statuses }}
13
-
{{ $success := index $c "success" }}
14
-
{{ $allPass := eq $success $total }}
15
-
16
-
{{ if $allPass }}
17
-
<div class="flex gap-1 items-center">
18
-
{{ i "check" "size-4 text-green-600 dark:text-green-400 " }}
19
-
<span>{{ $total }}/{{ $total }}</span>
20
-
</div>
21
-
{{ else }}
22
-
{{ $radius := f64 8 }}
23
-
{{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }}
24
-
{{ $offset := 0.0 }}
25
-
<div class="flex gap-1 items-center">
26
-
<svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20">
27
-
<circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/>
28
-
29
-
{{ range $kind, $count := $c }}
30
-
{{ $color := "" }}
31
-
{{ if or (eq $kind "pending") (eq $kind "running") }}
32
-
{{ $color = "#eab308" }}
33
-
{{ else if eq $kind "success" }}
34
-
{{ $color = "#10b981" }}
35
-
{{ else if eq $kind "cancelled" }}
36
-
{{ $color = "#6b7280" }}
37
-
{{ else }}
38
-
{{ $color = "#ef4444" }}
39
-
{{ end }}
40
-
41
-
{{ $percent := divf64 (f64 $count) (f64 $total) }}
42
-
{{ $length := mulf64 $percent $circumference }}
43
-
44
-
<circle
45
-
cx="10" cy="10" r="{{ $radius }}"
46
-
fill="none"
47
-
stroke="{{ $color }}"
48
-
stroke-width="2"
49
-
stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}"
50
-
stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}"
51
-
/>
52
-
{{ $offset = addf64 $offset $length }}
53
-
{{ end }}
54
-
</svg>
55
-
<span>{{$success}}/{{ $total }}</span>
56
-
</div>
57
-
{{ end }}
58
-
</div>
59
-
{{ end }}
60
-
61
-
{{ define "tooltip" }}
62
-
<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">
63
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700">
64
-
{{ range $name, $all := .Statuses }}
65
-
<div class="flex items-center justify-between p-1">
66
-
{{ $lastStatus := $all.Latest }}
67
-
{{ $kind := $lastStatus.Status.String }}
68
-
69
-
{{ $icon := "dot" }}
70
-
{{ $color := "text-gray-600 dark:text-gray-500" }}
71
-
{{ $text := "Failed" }}
72
-
{{ $time := "" }}
73
-
74
-
{{ if eq $kind "pending" }}
75
-
{{ $icon = "circle-dashed" }}
76
-
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
77
-
{{ $text = "Queued" }}
78
-
{{ $time = timeFmt $lastStatus.Created }}
79
-
{{ else if eq $kind "running" }}
80
-
{{ $icon = "circle-dashed" }}
81
-
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
82
-
{{ $text = "Running" }}
83
-
{{ $time = timeFmt $lastStatus.Created }}
84
-
{{ else if eq $kind "success" }}
85
-
{{ $icon = "check" }}
86
-
{{ $color = "text-green-600 dark:text-green-500" }}
87
-
{{ $text = "Success" }}
88
-
{{ with $all.TimeTaken }}
89
-
{{ $time = durationFmt . }}
90
-
{{ end }}
91
-
{{ else if eq $kind "cancelled" }}
92
-
{{ $icon = "circle-slash" }}
93
-
{{ $color = "text-gray-600 dark:text-gray-500" }}
94
-
{{ $text = "Cancelled" }}
95
-
{{ with $all.TimeTaken }}
96
-
{{ $time = durationFmt . }}
97
-
{{ end }}
98
-
{{ else }}
99
-
{{ $icon = "x" }}
100
-
{{ $color = "text-red-600 dark:text-red-500" }}
101
-
{{ $text = "Failed" }}
102
-
{{ with $all.TimeTaken }}
103
-
{{ $time = durationFmt . }}
104
-
{{ end }}
105
-
{{ end }}
106
-
107
-
<div id="left" class="flex items-center gap-2 flex-shrink-0">
108
-
{{ i $icon "size-4" $color }}
109
-
{{ $name }}
110
-
</div>
111
-
<div id="right" class="flex items-center gap-2 flex-shrink-0">
112
-
<span class="font-bold">{{ $text }}</span>
113
-
<time class="text-gray-400 dark:text-gray-600">{{ $time }}</time>
114
-
</div>
115
-
</div>
116
-
{{ end }}
117
-
</div>
118
-
</div>
119
-
{{ end }}
+118
appview/spindleverify/verify.go
+118
appview/spindleverify/verify.go
···
1
+
package spindleverify
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"io"
8
+
"net/http"
9
+
"strings"
10
+
"time"
11
+
12
+
"tangled.sh/tangled.sh/core/appview/db"
13
+
"tangled.sh/tangled.sh/core/rbac"
14
+
)
15
+
16
+
var (
17
+
FetchError = errors.New("failed to fetch owner")
18
+
)
19
+
20
+
// TODO: move this to "spindleclient" or similar
21
+
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
22
+
scheme := "https"
23
+
if dev {
24
+
scheme = "http"
25
+
}
26
+
27
+
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
28
+
req, err := http.NewRequest("GET", url, nil)
29
+
if err != nil {
30
+
return "", err
31
+
}
32
+
33
+
client := &http.Client{
34
+
Timeout: 1 * time.Second,
35
+
}
36
+
37
+
resp, err := client.Do(req.WithContext(ctx))
38
+
if err != nil || resp.StatusCode != 200 {
39
+
return "", fmt.Errorf("failed to fetch /owner")
40
+
}
41
+
42
+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
43
+
if err != nil {
44
+
return "", fmt.Errorf("failed to read /owner response: %w", err)
45
+
}
46
+
47
+
did := strings.TrimSpace(string(body))
48
+
if did == "" {
49
+
return "", fmt.Errorf("empty DID in /owner response")
50
+
}
51
+
52
+
return did, nil
53
+
}
54
+
55
+
type OwnerMismatch struct {
56
+
expected string
57
+
observed string
58
+
}
59
+
60
+
func (e *OwnerMismatch) Error() string {
61
+
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
62
+
}
63
+
64
+
func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error {
65
+
// begin verification
66
+
observedOwner, err := fetchOwner(ctx, instance, dev)
67
+
if err != nil {
68
+
return fmt.Errorf("%w: %w", FetchError, err)
69
+
}
70
+
71
+
if observedOwner != expectedOwner {
72
+
return &OwnerMismatch{
73
+
expected: expectedOwner,
74
+
observed: observedOwner,
75
+
}
76
+
}
77
+
78
+
return nil
79
+
}
80
+
81
+
// mark this spindle as verified in the DB and add this user as its owner
82
+
func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
83
+
tx, err := d.Begin()
84
+
if err != nil {
85
+
return 0, fmt.Errorf("failed to create txn: %w", err)
86
+
}
87
+
defer func() {
88
+
tx.Rollback()
89
+
e.E.LoadPolicy()
90
+
}()
91
+
92
+
// mark this spindle as verified in the db
93
+
rowId, err := db.VerifySpindle(
94
+
tx,
95
+
db.FilterEq("owner", owner),
96
+
db.FilterEq("instance", instance),
97
+
)
98
+
if err != nil {
99
+
return 0, fmt.Errorf("failed to write to DB: %w", err)
100
+
}
101
+
102
+
err = e.AddSpindleOwner(instance, owner)
103
+
if err != nil {
104
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
105
+
}
106
+
107
+
err = tx.Commit()
108
+
if err != nil {
109
+
return 0, fmt.Errorf("failed to commit txn: %w", err)
110
+
}
111
+
112
+
err = e.E.SavePolicy()
113
+
if err != nil {
114
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
115
+
}
116
+
117
+
return rowId, nil
118
+
}
+27
-12
spindle/engine/logger.go
+27
-12
spindle/engine/logger.go
···
41
41
42
42
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
43
43
// TODO: emit stream
44
-
return &jsonWriter{logger: l, kind: models.LogKindData}
44
+
return &dataWriter{
45
+
logger: l,
46
+
stream: stream,
47
+
}
45
48
}
46
49
47
-
func (l *WorkflowLogger) ControlWriter() io.Writer {
48
-
return &jsonWriter{logger: l, kind: models.LogKindControl}
50
+
func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer {
51
+
return &controlWriter{
52
+
logger: l,
53
+
idx: idx,
54
+
step: step,
55
+
}
49
56
}
50
57
51
-
type jsonWriter struct {
58
+
type dataWriter struct {
52
59
logger *WorkflowLogger
53
-
kind models.LogKind
60
+
stream string
54
61
}
55
62
56
-
func (w *jsonWriter) Write(p []byte) (int, error) {
63
+
func (w *dataWriter) Write(p []byte) (int, error) {
57
64
line := strings.TrimRight(string(p), "\r\n")
58
-
59
-
entry := models.LogLine{
60
-
Kind: w.kind,
61
-
Content: line,
65
+
entry := models.NewDataLogLine(line, w.stream)
66
+
if err := w.logger.encoder.Encode(entry); err != nil {
67
+
return 0, err
62
68
}
69
+
return len(p), nil
70
+
}
71
+
72
+
type controlWriter struct {
73
+
logger *WorkflowLogger
74
+
idx int
75
+
step models.Step
76
+
}
63
77
78
+
func (w *controlWriter) Write(_ []byte) (int, error) {
79
+
entry := models.NewControlLogLine(w.idx, w.step)
64
80
if err := w.logger.encoder.Encode(entry); err != nil {
65
81
return 0, err
66
82
}
67
-
68
-
return len(p), nil
83
+
return len(w.step.Name), nil
69
84
}
+85
-54
lexicons/pipeline.json
+85
-54
lexicons/pipeline.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": ["triggerMetadata", "workflows"],
12
+
"required": [
13
+
"triggerMetadata",
14
+
"workflows"
15
+
],
13
16
"properties": {
14
17
"triggerMetadata": {
15
18
"type": "ref",
···
27
30
},
28
31
"triggerMetadata": {
29
32
"type": "object",
30
-
"required": ["kind", "repo"],
33
+
"required": [
34
+
"kind",
35
+
"repo"
36
+
],
31
37
"properties": {
32
38
"kind": {
33
39
"type": "string",
34
-
"enum": ["push", "pull_request", "manual"]
40
+
"enum": [
41
+
"push",
42
+
"pull_request",
43
+
"manual"
44
+
]
35
45
},
36
46
"repo": {
37
47
"type": "ref",
···
53
63
},
54
64
"triggerRepo": {
55
65
"type": "object",
56
-
"required": ["knot", "did", "repo", "defaultBranch"],
66
+
"required": [
67
+
"knot",
68
+
"did",
69
+
"repo",
70
+
"defaultBranch"
71
+
],
57
72
"properties": {
58
73
"knot": {
59
74
"type": "string"
···
72
87
},
73
88
"pushTriggerData": {
74
89
"type": "object",
75
-
"required": ["ref", "newSha", "oldSha"],
90
+
"required": [
91
+
"ref",
92
+
"newSha",
93
+
"oldSha"
94
+
],
76
95
"properties": {
77
96
"ref": {
78
97
"type": "string"
···
91
110
},
92
111
"pullRequestTriggerData": {
93
112
"type": "object",
94
-
"required": ["sourceBranch", "targetBranch", "sourceSha", "action"],
113
+
"required": [
114
+
"sourceBranch",
115
+
"targetBranch",
116
+
"sourceSha",
117
+
"action"
118
+
],
95
119
"properties": {
96
120
"sourceBranch": {
97
121
"type": "string"
···
115
139
"inputs": {
116
140
"type": "array",
117
141
"items": {
118
-
"type": "object",
119
-
"required": ["key", "value"],
120
-
"properties": {
121
-
"key": {
122
-
"type": "string"
123
-
},
124
-
"value": {
125
-
"type": "string"
126
-
}
127
-
}
142
+
"type": "ref",
143
+
"ref": "#pair"
128
144
}
129
145
}
130
146
}
131
147
},
132
148
"workflow": {
133
149
"type": "object",
134
-
"required": ["name", "dependencies", "steps", "environment", "clone"],
150
+
"required": [
151
+
"name",
152
+
"dependencies",
153
+
"steps",
154
+
"environment",
155
+
"clone"
156
+
],
135
157
"properties": {
136
158
"name": {
137
159
"type": "string"
138
160
},
139
161
"dependencies": {
140
-
"type": "ref",
141
-
"ref": "#dependencies"
162
+
"type": "array",
163
+
"items": {
164
+
"type": "ref",
165
+
"ref": "#dependency"
166
+
}
142
167
},
143
168
"steps": {
144
169
"type": "array",
···
150
175
"environment": {
151
176
"type": "array",
152
177
"items": {
153
-
"type": "object",
154
-
"required": ["key", "value"],
155
-
"properties": {
156
-
"key": {
157
-
"type": "string"
158
-
},
159
-
"value": {
160
-
"type": "string"
161
-
}
162
-
}
178
+
"type": "ref",
179
+
"ref": "#pair"
163
180
}
164
181
},
165
182
"clone": {
···
168
185
}
169
186
}
170
187
},
171
-
"dependencies": {
172
-
"type": "array",
173
-
"items": {
174
-
"type": "object",
175
-
"required": ["registry", "packages"],
176
-
"properties": {
177
-
"registry": {
188
+
"dependency": {
189
+
"type": "object",
190
+
"required": [
191
+
"registry",
192
+
"packages"
193
+
],
194
+
"properties": {
195
+
"registry": {
196
+
"type": "string"
197
+
},
198
+
"packages": {
199
+
"type": "array",
200
+
"items": {
178
201
"type": "string"
179
-
},
180
-
"packages": {
181
-
"type": "array",
182
-
"items": {
183
-
"type": "string"
184
-
}
185
202
}
186
203
}
187
204
}
188
205
},
189
206
"cloneOpts": {
190
207
"type": "object",
191
-
"required": ["skip", "depth", "submodules"],
208
+
"required": [
209
+
"skip",
210
+
"depth",
211
+
"submodules"
212
+
],
192
213
"properties": {
193
214
"skip": {
194
215
"type": "boolean"
···
203
224
},
204
225
"step": {
205
226
"type": "object",
206
-
"required": ["name", "command"],
227
+
"required": [
228
+
"name",
229
+
"command"
230
+
],
207
231
"properties": {
208
232
"name": {
209
233
"type": "string"
···
214
238
"environment": {
215
239
"type": "array",
216
240
"items": {
217
-
"type": "object",
218
-
"required": ["key", "value"],
219
-
"properties": {
220
-
"key": {
221
-
"type": "string"
222
-
},
223
-
"value": {
224
-
"type": "string"
225
-
}
226
-
}
241
+
"type": "ref",
242
+
"ref": "#pair"
227
243
}
228
244
}
229
245
}
246
+
},
247
+
"pair": {
248
+
"type": "object",
249
+
"required": [
250
+
"key",
251
+
"value"
252
+
],
253
+
"properties": {
254
+
"key": {
255
+
"type": "string"
256
+
},
257
+
"value": {
258
+
"type": "string"
259
+
}
260
+
}
230
261
}
231
262
}
232
263
}
+1
-1
spindle/engine/envs_test.go
+1
-1
spindle/engine/envs_test.go
+8
-1
docs/spindle/hosting.md
+8
-1
docs/spindle/hosting.md
···
36
36
go build -o cmd/spindle/spindle cmd/spindle/main.go
37
37
```
38
38
39
-
3. **Run the Spindle binary.**
39
+
3. **Create the log directory.**
40
+
41
+
```shell
42
+
sudo mkdir -p /var/log/spindle
43
+
sudo chown $USER:$USER -R /var/log/spindle
44
+
```
45
+
46
+
4. **Run the Spindle binary.**
40
47
41
48
```shell
42
49
./cmd/spindle/spindle
+24
api/tangled/feedreaction.go
+24
api/tangled/feedreaction.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.feed.reaction
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
FeedReactionNSID = "sh.tangled.feed.reaction"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{})
17
+
} //
18
+
// RECORDTYPE: FeedReaction
19
+
type FeedReaction struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
Reaction string `json:"reaction" cborgen:"reaction"`
23
+
Subject string `json:"subject" cborgen:"subject"`
24
+
}
+34
lexicons/feed/reaction.json
+34
lexicons/feed/reaction.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.feed.reaction",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"reaction",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "at-uri"
21
+
},
22
+
"reaction": {
23
+
"type": "string",
24
+
"enum": [ "👍", "👎", "😆", "🎉", "🫤", "❤️", "🚀", "👀" ]
25
+
},
26
+
"createdAt": {
27
+
"type": "string",
28
+
"format": "datetime"
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
-93
appview/pages/templates/knots.html
-93
appview/pages/templates/knots.html
···
1
-
{{ define "title" }}knots{{ end }}
2
-
{{ define "content" }}
3
-
<div class="p-6">
4
-
<p class="text-xl font-bold dark:text-white">Knots</p>
5
-
</div>
6
-
<div class="flex flex-col">
7
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2>
8
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
9
-
<p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p>
10
-
<form
11
-
hx-post="/knots/key"
12
-
class="max-w-2xl mb-8 space-y-4"
13
-
hx-indicator="#generate-knot-key-spinner"
14
-
>
15
-
<input
16
-
type="text"
17
-
id="domain"
18
-
name="domain"
19
-
placeholder="knot.example.com"
20
-
required
21
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
22
-
>
23
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex items-center" type="submit">
24
-
<span>generate key</span>
25
-
<span id="generate-knot-key-spinner" class="group">
26
-
{{ i "loader-circle" "pl-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
27
-
</span>
28
-
</button>
29
-
<div id="settings-knots-error" class="error dark:text-red-400"></div>
30
-
</form>
31
-
</section>
32
-
33
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2>
34
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
35
-
<div id="knots-list" class="flex flex-col gap-6 mb-8">
36
-
{{ range .Registrations }}
37
-
{{ if .Registered }}
38
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
39
-
<div class="flex flex-col gap-1">
40
-
<div class="inline-flex items-center gap-4">
41
-
{{ i "git-branch" "w-3 h-3 dark:text-gray-300" }}
42
-
<a href="/knots/{{ .Domain }}">
43
-
<p class="font-bold dark:text-white">{{ .Domain }}</p>
44
-
</a>
45
-
</div>
46
-
<p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p>
47
-
<p class="text-sm text-gray-500 dark:text-gray-400">registered {{ template "repo/fragments/time" .Registered }}</p>
48
-
</div>
49
-
</div>
50
-
{{ end }}
51
-
{{ else }}
52
-
<p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p>
53
-
{{ end }}
54
-
</div>
55
-
</section>
56
-
57
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2>
58
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
59
-
<div id="pending-knots-list" class="flex flex-col gap-6 mb-8">
60
-
{{ range .Registrations }}
61
-
{{ if not .Registered }}
62
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
63
-
<div class="flex flex-col gap-1">
64
-
<div class="inline-flex items-center gap-4">
65
-
<p class="font-bold dark:text-white">{{ .Domain }}</p>
66
-
<div class="inline-flex items-center gap-1">
67
-
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">
68
-
pending
69
-
</span>
70
-
</div>
71
-
</div>
72
-
<p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p>
73
-
<p class="text-sm text-gray-500 dark:text-gray-400">created {{ template "repo/fragments/time" .Created }}</p>
74
-
</div>
75
-
<div class="flex gap-2 items-center">
76
-
<button
77
-
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group"
78
-
hx-post="/knots/{{ .Domain }}/init"
79
-
>
80
-
{{ i "square-play" "w-5 h-5" }}
81
-
<span class="hidden md:inline">initialize</span>
82
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
83
-
</button>
84
-
</div>
85
-
</div>
86
-
{{ end }}
87
-
{{ else }}
88
-
<p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p>
89
-
{{ end }}
90
-
</div>
91
-
</section>
92
-
</div>
93
-
{{ end }}
+42
knotserver/git/cmd.go
+42
knotserver/git/cmd.go
···
1
+
package git
2
+
3
+
import (
4
+
"fmt"
5
+
"os/exec"
6
+
)
7
+
8
+
const (
9
+
fieldSeparator = "\x1f" // ASCII Unit Separator
10
+
recordSeparator = "\x1e" // ASCII Record Separator
11
+
)
12
+
13
+
func (g *GitRepo) runGitCmd(command string, extraArgs ...string) ([]byte, error) {
14
+
var args []string
15
+
args = append(args, command)
16
+
args = append(args, extraArgs...)
17
+
18
+
cmd := exec.Command("git", args...)
19
+
cmd.Dir = g.path
20
+
21
+
out, err := cmd.Output()
22
+
if err != nil {
23
+
if exitErr, ok := err.(*exec.ExitError); ok {
24
+
return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr))
25
+
}
26
+
return nil, err
27
+
}
28
+
29
+
return out, nil
30
+
}
31
+
32
+
func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) {
33
+
return g.runGitCmd("rev-list", extraArgs...)
34
+
}
35
+
36
+
func (g *GitRepo) forEachRef(extraArgs ...string) ([]byte, error) {
37
+
return g.runGitCmd("for-each-ref", extraArgs...)
38
+
}
39
+
40
+
func (g *GitRepo) revParse(extraArgs ...string) ([]byte, error) {
41
+
return g.runGitCmd("rev-parse", extraArgs...)
42
+
}
-11
appview/tid.go
-11
appview/tid.go
-48
appview/pages/templates/repo/fragments/repoActions.html
-48
appview/pages/templates/repo/fragments/repoActions.html
···
1
-
{{ define "repo/fragments/repoActions" }}
2
-
<div class="flex items-center gap-2 z-auto">
3
-
<button
4
-
id="starBtn"
5
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
6
-
{{ if .IsStarred }}
7
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
8
-
{{ else }}
9
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
10
-
{{ end }}
11
-
12
-
hx-trigger="click"
13
-
hx-target="#starBtn"
14
-
hx-swap="outerHTML"
15
-
hx-disabled-elt="#starBtn"
16
-
>
17
-
{{ if .IsStarred }}
18
-
{{ i "star" "w-4 h-4 fill-current" }}
19
-
{{ else }}
20
-
{{ i "star" "w-4 h-4" }}
21
-
{{ end }}
22
-
<span class="text-sm">
23
-
{{ .Stats.StarCount }}
24
-
</span>
25
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
26
-
</button>
27
-
{{ if .DisableFork }}
28
-
<button
29
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
30
-
disabled
31
-
title="Empty repositories cannot be forked"
32
-
>
33
-
{{ i "git-fork" "w-4 h-4" }}
34
-
fork
35
-
</button>
36
-
{{ else }}
37
-
<a
38
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
39
-
hx-boost="true"
40
-
href="/{{ .FullName }}/fork"
41
-
>
42
-
{{ i "git-fork" "w-4 h-4" }}
43
-
fork
44
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
-
</a>
46
-
{{ end }}
47
-
</div>
48
-
{{ end }}
+3
-1
api/tangled/stateclosed.go
+3
-1
api/tangled/stateclosed.go
+3
-1
api/tangled/stateopen.go
+3
-1
api/tangled/stateopen.go
+3
-1
api/tangled/statusclosed.go
+3
-1
api/tangled/statusclosed.go
+3
-1
api/tangled/statusmerged.go
+3
-1
api/tangled/statusmerged.go
+3
-1
api/tangled/statusopen.go
+3
-1
api/tangled/statusopen.go
+4
appview/idresolver/resolver.go
+4
appview/idresolver/resolver.go
+2
-3
appview/idresolver/resolver.go
idresolver/resolver.go
+2
-3
appview/idresolver/resolver.go
idresolver/resolver.go
···
11
11
"github.com/bluesky-social/indigo/atproto/identity/redisdir"
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
13
"github.com/carlmjohnson/versioninfo"
14
-
"tangled.sh/tangled.sh/core/appview/config"
15
14
)
16
15
17
16
type Resolver struct {
···
56
55
}
57
56
}
58
57
59
-
func RedisResolver(config config.RedisConfig) (*Resolver, error) {
60
-
directory, err := RedisDirectory(config.ToURL())
58
+
func RedisResolver(redisUrl string) (*Resolver, error) {
59
+
directory, err := RedisDirectory(redisUrl)
61
60
if err != nil {
62
61
return nil, err
63
62
}
+30
api/tangled/reposetDefaultBranch.go
+30
api/tangled/reposetDefaultBranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.setDefaultBranch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoSetDefaultBranchNSID = "sh.tangled.repo.setDefaultBranch"
15
+
)
16
+
17
+
// RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call.
18
+
type RepoSetDefaultBranch_Input struct {
19
+
DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch".
24
+
func RepoSetDefaultBranch(ctx context.Context, c util.LexClient, input *RepoSetDefaultBranch_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.setDefaultBranch", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
+29
lexicons/defaultBranch.json
+29
lexicons/defaultBranch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.setDefaultBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Set the default branch for a repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"defaultBranch"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"defaultBranch": {
22
+
"type": "string"
23
+
}
24
+
}
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
+6
-1
hook/setup.go
+6
-1
hook/setup.go
···
133
133
134
134
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
135
135
# AUTO GENERATED BY KNOT, DO NOT MODIFY
136
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve
136
+
push_options=()
137
+
for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do
138
+
option_var="GIT_PUSH_OPTION_$i"
139
+
push_options+=(-push-option "${!option_var}")
140
+
done
141
+
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve
137
142
`, executablePath, config.internalApi)
138
143
139
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+14
hook/hook.go
+14
hook/hook.go
···
3
3
import (
4
4
"bufio"
5
5
"context"
6
+
"encoding/json"
6
7
"fmt"
7
8
"net/http"
8
9
"os"
···
11
12
"github.com/urfave/cli/v3"
12
13
)
13
14
15
+
type HookResponse struct {
16
+
Messages []string `json:"messages"`
17
+
}
18
+
14
19
// The hook command is nested like so:
15
20
//
16
21
// knot hook --[flags] [hook]
···
88
93
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
89
94
}
90
95
96
+
var data HookResponse
97
+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
98
+
return fmt.Errorf("failed to decode response: %w", err)
99
+
}
100
+
101
+
for _, message := range data.Messages {
102
+
fmt.Println(message)
103
+
}
104
+
91
105
return nil
92
106
}
+31
api/tangled/repoaddSecret.go
+31
api/tangled/repoaddSecret.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.addSecret
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoAddSecretNSID = "sh.tangled.repo.addSecret"
15
+
)
16
+
17
+
// RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call.
18
+
type RepoAddSecret_Input struct {
19
+
Key string `json:"key" cborgen:"key"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
Value string `json:"value" cborgen:"value"`
22
+
}
23
+
24
+
// RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret".
25
+
func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error {
26
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil {
27
+
return err
28
+
}
29
+
30
+
return nil
31
+
}
+41
api/tangled/repolistSecrets.go
+41
api/tangled/repolistSecrets.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.listSecrets
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoListSecretsNSID = "sh.tangled.repo.listSecrets"
15
+
)
16
+
17
+
// RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call.
18
+
type RepoListSecrets_Output struct {
19
+
Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"`
20
+
}
21
+
22
+
// RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema.
23
+
type RepoListSecrets_Secret struct {
24
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
25
+
CreatedBy string `json:"createdBy" cborgen:"createdBy"`
26
+
Key string `json:"key" cborgen:"key"`
27
+
Repo string `json:"repo" cborgen:"repo"`
28
+
}
29
+
30
+
// RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets".
31
+
func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) {
32
+
var out RepoListSecrets_Output
33
+
34
+
params := map[string]interface{}{}
35
+
params["repo"] = repo
36
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", params, nil, &out); err != nil {
37
+
return nil, err
38
+
}
39
+
40
+
return &out, nil
41
+
}
+30
api/tangled/reporemoveSecret.go
+30
api/tangled/reporemoveSecret.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.removeSecret
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret"
15
+
)
16
+
17
+
// RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call.
18
+
type RepoRemoveSecret_Input struct {
19
+
Key string `json:"key" cborgen:"key"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret".
24
+
func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
+37
lexicons/addSecret.json
+37
lexicons/addSecret.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.addSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Add a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key",
15
+
"value"
16
+
],
17
+
"properties": {
18
+
"repo": {
19
+
"type": "string",
20
+
"format": "at-uri"
21
+
},
22
+
"key": {
23
+
"type": "string",
24
+
"maxLength": 50,
25
+
"minLength": 1
26
+
},
27
+
"value": {
28
+
"type": "string",
29
+
"maxLength": 200,
30
+
"minLength": 1
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
}
+67
lexicons/listSecrets.json
+67
lexicons/listSecrets.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.listSecrets",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": [
10
+
"repo"
11
+
],
12
+
"properties": {
13
+
"repo": {
14
+
"type": "string",
15
+
"format": "at-uri"
16
+
}
17
+
}
18
+
},
19
+
"output": {
20
+
"encoding": "application/json",
21
+
"schema": {
22
+
"type": "object",
23
+
"required": [
24
+
"secrets"
25
+
],
26
+
"properties": {
27
+
"secrets": {
28
+
"type": "array",
29
+
"items": {
30
+
"type": "ref",
31
+
"ref": "#secret"
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
},
38
+
"secret": {
39
+
"type": "object",
40
+
"required": [
41
+
"repo",
42
+
"key",
43
+
"createdAt",
44
+
"createdBy"
45
+
],
46
+
"properties": {
47
+
"repo": {
48
+
"type": "string",
49
+
"format": "at-uri"
50
+
},
51
+
"key": {
52
+
"type": "string",
53
+
"maxLength": 50,
54
+
"minLength": 1
55
+
},
56
+
"createdAt": {
57
+
"type": "string",
58
+
"format": "datetime"
59
+
},
60
+
"createdBy": {
61
+
"type": "string",
62
+
"format": "did"
63
+
}
64
+
}
65
+
}
66
+
}
67
+
}
+31
lexicons/removeSecret.json
+31
lexicons/removeSecret.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.removeSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Remove a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"key": {
22
+
"type": "string",
23
+
"maxLength": 50,
24
+
"minLength": 1
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
30
+
}
31
+
}
+25
spindle/motd
+25
spindle/motd
···
1
+
****
2
+
*** ***
3
+
*** ** ****** **
4
+
** * *****
5
+
* ** **
6
+
* * * ***************
7
+
** ** *# **
8
+
* ** ** *** **
9
+
* * ** ** * ******
10
+
* ** ** * ** * *
11
+
** ** *** ** ** *
12
+
** ** * ** * *
13
+
** **** ** * *
14
+
** *** ** ** **
15
+
*** ** *****
16
+
********************
17
+
**
18
+
*
19
+
#**************
20
+
**
21
+
********
22
+
23
+
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle
24
+
25
+
Most API routes are under /xrpc/
+2
-2
appview/pages/markup/camo.go
+2
-2
appview/pages/markup/camo.go
···
9
9
"github.com/yuin/goldmark/ast"
10
10
)
11
11
12
-
func generateCamoURL(baseURL, secret, imageURL string) string {
12
+
func GenerateCamoURL(baseURL, secret, imageURL string) string {
13
13
h := hmac.New(sha256.New, []byte(secret))
14
14
h.Write([]byte(imageURL))
15
15
signature := hex.EncodeToString(h.Sum(nil))
···
24
24
}
25
25
26
26
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
27
-
return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
27
+
return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
28
28
}
29
29
30
30
return dst
+104
appview/signup/requests.go
+104
appview/signup/requests.go
···
1
+
package signup
2
+
3
+
// We have this extra code here for now since the xrpcclient package
4
+
// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth.
5
+
6
+
import (
7
+
"bytes"
8
+
"encoding/json"
9
+
"fmt"
10
+
"io"
11
+
"net/http"
12
+
"net/url"
13
+
)
14
+
15
+
// makePdsRequest is a helper method to make requests to the PDS service
16
+
func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) {
17
+
jsonData, err := json.Marshal(body)
18
+
if err != nil {
19
+
return nil, err
20
+
}
21
+
22
+
url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint)
23
+
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
24
+
if err != nil {
25
+
return nil, err
26
+
}
27
+
28
+
req.Header.Set("Content-Type", "application/json")
29
+
30
+
if useAuth {
31
+
req.SetBasicAuth("admin", s.config.Pds.AdminSecret)
32
+
}
33
+
34
+
return http.DefaultClient.Do(req)
35
+
}
36
+
37
+
// handlePdsError processes error responses from the PDS service
38
+
func (s *Signup) handlePdsError(resp *http.Response, action string) error {
39
+
var errorResp struct {
40
+
Error string `json:"error"`
41
+
Message string `json:"message"`
42
+
}
43
+
44
+
respBody, _ := io.ReadAll(resp.Body)
45
+
if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" {
46
+
return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message)
47
+
}
48
+
49
+
// Fallback if we couldn't parse the error
50
+
return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode)
51
+
}
52
+
53
+
func (s *Signup) inviteCodeRequest() (string, error) {
54
+
body := map[string]any{"useCount": 1}
55
+
56
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true)
57
+
if err != nil {
58
+
return "", err
59
+
}
60
+
defer resp.Body.Close()
61
+
62
+
if resp.StatusCode != http.StatusOK {
63
+
return "", s.handlePdsError(resp, "create invite code")
64
+
}
65
+
66
+
var result map[string]string
67
+
json.NewDecoder(resp.Body).Decode(&result)
68
+
return result["code"], nil
69
+
}
70
+
71
+
func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) {
72
+
parsedURL, err := url.Parse(s.config.Pds.Host)
73
+
if err != nil {
74
+
return "", fmt.Errorf("invalid PDS host URL: %w", err)
75
+
}
76
+
77
+
pdsDomain := parsedURL.Hostname()
78
+
79
+
body := map[string]string{
80
+
"email": email,
81
+
"handle": fmt.Sprintf("%s.%s", username, pdsDomain),
82
+
"password": password,
83
+
"inviteCode": code,
84
+
}
85
+
86
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false)
87
+
if err != nil {
88
+
return "", err
89
+
}
90
+
defer resp.Body.Close()
91
+
92
+
if resp.StatusCode != http.StatusOK {
93
+
return "", s.handlePdsError(resp, "create account")
94
+
}
95
+
96
+
var result struct {
97
+
DID string `json:"did"`
98
+
}
99
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
100
+
return "", fmt.Errorf("failed to decode create account response: %w", err)
101
+
}
102
+
103
+
return result.DID, nil
104
+
}
lexicons/addSecret.json
lexicons/repo/addSecret.json
lexicons/addSecret.json
lexicons/repo/addSecret.json
lexicons/artifact.json
lexicons/repo/artifact.json
lexicons/artifact.json
lexicons/repo/artifact.json
lexicons/defaultBranch.json
lexicons/repo/defaultBranch.json
lexicons/defaultBranch.json
lexicons/repo/defaultBranch.json
lexicons/listSecrets.json
lexicons/repo/listSecrets.json
lexicons/listSecrets.json
lexicons/repo/listSecrets.json
lexicons/removeSecret.json
lexicons/repo/removeSecret.json
lexicons/removeSecret.json
lexicons/repo/removeSecret.json
lexicons/spindle.json
lexicons/spindle/spindle.json
lexicons/spindle.json
lexicons/spindle/spindle.json
+25
api/tangled/repocollaborator.go
+25
api/tangled/repocollaborator.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.collaborator
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
RepoCollaboratorNSID = "sh.tangled.repo.collaborator"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{})
17
+
} //
18
+
// RECORDTYPE: RepoCollaborator
19
+
type RepoCollaborator struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
// repo: repo to add this user to
23
+
Repo string `json:"repo" cborgen:"repo"`
24
+
Subject string `json:"subject" cborgen:"subject"`
25
+
}
+36
lexicons/repo/collaborator.json
+36
lexicons/repo/collaborator.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.collaborator",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"repo",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "did"
21
+
},
22
+
"repo": {
23
+
"type": "string",
24
+
"description": "repo to add this user to",
25
+
"format": "at-uri"
26
+
},
27
+
"createdAt": {
28
+
"type": "string",
29
+
"format": "datetime"
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
+25
api/tangled/tangledstring.go
+25
api/tangled/tangledstring.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.string
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
StringNSID = "sh.tangled.string"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.string", &String{})
17
+
} //
18
+
// RECORDTYPE: String
19
+
type String struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"`
21
+
Contents string `json:"contents" cborgen:"contents"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Description string `json:"description" cborgen:"description"`
24
+
Filename string `json:"filename" cborgen:"filename"`
25
+
}
+40
lexicons/string/string.json
+40
lexicons/string/string.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.string",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"filename",
14
+
"description",
15
+
"createdAt",
16
+
"contents"
17
+
],
18
+
"properties": {
19
+
"filename": {
20
+
"type": "string",
21
+
"maxGraphemes": 140,
22
+
"minGraphemes": 1
23
+
},
24
+
"description": {
25
+
"type": "string",
26
+
"maxGraphemes": 280
27
+
},
28
+
"createdAt": {
29
+
"type": "string",
30
+
"format": "datetime"
31
+
},
32
+
"contents": {
33
+
"type": "string",
34
+
"minGraphemes": 1
35
+
}
36
+
}
37
+
}
38
+
}
39
+
}
40
+
}
+2
-3
nix/pkgs/spindle.nix
+2
-3
nix/pkgs/spindle.nix
+59
spindle/db/member.go
+59
spindle/db/member.go
···
1
+
package db
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type SpindleMember struct {
10
+
Id int
11
+
Did syntax.DID // owner of the record
12
+
Rkey string // rkey of the record
13
+
Instance string
14
+
Subject syntax.DID // the member being added
15
+
Created time.Time
16
+
}
17
+
18
+
func AddSpindleMember(db *DB, member SpindleMember) error {
19
+
_, err := db.Exec(
20
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
21
+
member.Did,
22
+
member.Rkey,
23
+
member.Instance,
24
+
member.Subject,
25
+
)
26
+
return err
27
+
}
28
+
29
+
func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
30
+
_, err := db.Exec(
31
+
"delete from spindle_members where did = ? and rkey = ?",
32
+
owner_did,
33
+
rkey,
34
+
)
35
+
return err
36
+
}
37
+
38
+
func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
39
+
query :=
40
+
`select id, did, rkey, instance, subject, created
41
+
from spindle_members
42
+
where did = ? and rkey = ?`
43
+
44
+
var member SpindleMember
45
+
var createdAt string
46
+
err := db.QueryRow(query, did, rkey).Scan(
47
+
&member.Id,
48
+
&member.Did,
49
+
&member.Rkey,
50
+
&member.Instance,
51
+
&member.Subject,
52
+
&createdAt,
53
+
)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
return &member, nil
59
+
}
+1
-1
spindle/secrets/openbao.go
+1
-1
spindle/secrets/openbao.go
-62
appview/db/migrations/20250305_113405.sql
-62
appview/db/migrations/20250305_113405.sql
···
1
-
-- Simplified SQLite Database Migration Script for Issues and Comments
2
-
3
-
-- Migration for issues table
4
-
CREATE TABLE issues_new (
5
-
id integer primary key autoincrement,
6
-
owner_did text not null,
7
-
repo_at text not null,
8
-
issue_id integer not null,
9
-
title text not null,
10
-
body text not null,
11
-
open integer not null default 1,
12
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
13
-
issue_at text,
14
-
unique(repo_at, issue_id),
15
-
foreign key (repo_at) references repos(at_uri) on delete cascade
16
-
);
17
-
18
-
-- Migrate data to new issues table
19
-
INSERT INTO issues_new (
20
-
id, owner_did, repo_at, issue_id,
21
-
title, body, open, created, issue_at
22
-
)
23
-
SELECT
24
-
id, owner_did, repo_at, issue_id,
25
-
title, body, open, created, issue_at
26
-
FROM issues;
27
-
28
-
-- Drop old issues table
29
-
DROP TABLE issues;
30
-
31
-
-- Rename new issues table
32
-
ALTER TABLE issues_new RENAME TO issues;
33
-
34
-
-- Migration for comments table
35
-
CREATE TABLE comments_new (
36
-
id integer primary key autoincrement,
37
-
owner_did text not null,
38
-
issue_id integer not null,
39
-
repo_at text not null,
40
-
comment_id integer not null,
41
-
comment_at text not null,
42
-
body text not null,
43
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
44
-
unique(issue_id, comment_id),
45
-
foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
46
-
);
47
-
48
-
-- Migrate data to new comments table
49
-
INSERT INTO comments_new (
50
-
id, owner_did, issue_id, repo_at,
51
-
comment_id, comment_at, body, created
52
-
)
53
-
SELECT
54
-
id, owner_did, issue_id, repo_at,
55
-
comment_id, comment_at, body, created
56
-
FROM comments;
57
-
58
-
-- Drop old comments table
59
-
DROP TABLE comments;
60
-
61
-
-- Rename new comments table
62
-
ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
-66
appview/db/migrations/validate.sql
···
1
-
-- Validation Queries for Database Migration
2
-
3
-
-- 1. Verify Issues Table Structure
4
-
PRAGMA table_info(issues);
5
-
6
-
-- 2. Verify Comments Table Structure
7
-
PRAGMA table_info(comments);
8
-
9
-
-- 3. Check Total Row Count Consistency
10
-
SELECT
11
-
'Issues Row Count' AS check_type,
12
-
(SELECT COUNT(*) FROM issues) AS row_count
13
-
UNION ALL
14
-
SELECT
15
-
'Comments Row Count' AS check_type,
16
-
(SELECT COUNT(*) FROM comments) AS row_count;
17
-
18
-
-- 4. Verify Unique Constraint on Issues
19
-
SELECT
20
-
repo_at,
21
-
issue_id,
22
-
COUNT(*) as duplicate_count
23
-
FROM issues
24
-
GROUP BY repo_at, issue_id
25
-
HAVING duplicate_count > 1;
26
-
27
-
-- 5. Verify Foreign Key Integrity for Comments
28
-
SELECT
29
-
'Orphaned Comments' AS check_type,
30
-
COUNT(*) AS orphaned_count
31
-
FROM comments c
32
-
LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id
33
-
WHERE i.id IS NULL;
34
-
35
-
-- 6. Check Foreign Key Constraint
36
-
PRAGMA foreign_key_list(comments);
37
-
38
-
-- 7. Sample Data Integrity Check
39
-
SELECT
40
-
'Sample Issues' AS check_type,
41
-
repo_at,
42
-
issue_id,
43
-
title,
44
-
created
45
-
FROM issues
46
-
LIMIT 5;
47
-
48
-
-- 8. Sample Comments Data Integrity Check
49
-
SELECT
50
-
'Sample Comments' AS check_type,
51
-
repo_at,
52
-
issue_id,
53
-
comment_id,
54
-
body,
55
-
created
56
-
FROM comments
57
-
LIMIT 5;
58
-
59
-
-- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness)
60
-
SELECT
61
-
issue_id,
62
-
comment_id,
63
-
COUNT(*) as duplicate_count
64
-
FROM comments
65
-
GROUP BY issue_id, comment_id
66
-
HAVING duplicate_count > 1;
+14
nix/modules/appview.nix
+14
nix/modules/appview.nix
···
27
27
default = "00000000000000000000000000000000";
28
28
description = "Cookie secret";
29
29
};
30
+
environmentFile = mkOption {
31
+
type = with types; nullOr path;
32
+
default = null;
33
+
example = "/etc/tangled-appview.env";
34
+
description = ''
35
+
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
+
37
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
38
+
passed to the service without makeing them world readable in the
39
+
nix store.
40
+
41
+
'';
42
+
};
30
43
};
31
44
};
32
45
···
39
52
ListenStream = "0.0.0.0:${toString cfg.port}";
40
53
ExecStart = "${cfg.package}/bin/appview";
41
54
Restart = "always";
55
+
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
42
56
};
43
57
44
58
environment = {
+2
-1
.gitignore
+2
-1
.gitignore
+1
-1
.air/appview.toml
+1
-1
.air/appview.toml
+1
-1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
+1
-1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
···
19
19
{{ $color = "text-gray-600 dark:text-gray-500" }}
20
20
{{ else if eq $kind "timeout" }}
21
21
{{ $icon = "clock-alert" }}
22
-
{{ $color = "text-orange-400 dark:text-orange-300" }}
22
+
{{ $color = "text-orange-400 dark:text-orange-500" }}
23
23
{{ else }}
24
24
{{ $icon = "x" }}
25
25
{{ $color = "text-red-600 dark:text-red-500" }}
+1
-1
tailwind.config.js
+1
-1
tailwind.config.js
···
36
36
css: {
37
37
maxWidth: "none",
38
38
pre: {
39
-
"@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {},
39
+
"@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {},
40
40
},
41
41
code: {
42
42
"@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
spindle/secrets/sqlite.go
+1
-1
spindle/secrets/sqlite.go
···
24
24
}
25
25
26
26
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
27
-
db, err := sql.Open("sqlite3", dbPath)
27
+
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
28
28
if err != nil {
29
29
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
30
30
}
+14
-9
knotserver/db/init.go
+14
-9
knotserver/db/init.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"strings"
5
6
6
7
_ "github.com/mattn/go-sqlite3"
7
8
)
···
11
12
}
12
13
13
14
func Setup(dbPath string) (*DB, error) {
14
-
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
15
+
// https://github.com/mattn/go-sqlite3#connection-string
16
+
opts := []string{
17
+
"_foreign_keys=1",
18
+
"_journal_mode=WAL",
19
+
"_synchronous=NORMAL",
20
+
"_auto_vacuum=incremental",
21
+
}
22
+
23
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
15
24
if err != nil {
16
25
return nil, err
17
26
}
18
27
19
-
_, err = db.Exec(`
20
-
pragma journal_mode = WAL;
21
-
pragma synchronous = normal;
22
-
pragma temp_store = memory;
23
-
pragma mmap_size = 30000000000;
24
-
pragma page_size = 32768;
25
-
pragma auto_vacuum = incremental;
26
-
pragma busy_timeout = 5000;
28
+
// NOTE: If any other migration is added here, you MUST
29
+
// copy the pattern in appview: use a single sql.Conn
30
+
// for every migration.
27
31
32
+
_, err = db.Exec(`
28
33
create table if not exists known_dids (
29
34
did text primary key
30
35
);
+14
-9
spindle/db/db.go
+14
-9
spindle/db/db.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"strings"
5
6
6
7
_ "github.com/mattn/go-sqlite3"
7
8
)
···
11
12
}
12
13
13
14
func Make(dbPath string) (*DB, error) {
14
-
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
15
+
// https://github.com/mattn/go-sqlite3#connection-string
16
+
opts := []string{
17
+
"_foreign_keys=1",
18
+
"_journal_mode=WAL",
19
+
"_synchronous=NORMAL",
20
+
"_auto_vacuum=incremental",
21
+
}
22
+
23
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
15
24
if err != nil {
16
25
return nil, err
17
26
}
18
27
19
-
_, err = db.Exec(`
20
-
pragma journal_mode = WAL;
21
-
pragma synchronous = normal;
22
-
pragma temp_store = memory;
23
-
pragma mmap_size = 30000000000;
24
-
pragma page_size = 32768;
25
-
pragma auto_vacuum = incremental;
26
-
pragma busy_timeout = 5000;
28
+
// NOTE: If any other migration is added here, you MUST
29
+
// copy the pattern in appview: use a single sql.Conn
30
+
// for every migration.
27
31
32
+
_, err = db.Exec(`
28
33
create table if not exists _jetstream (
29
34
id integer primary key autoincrement,
30
35
last_time_us integer not null
+12
.prettierrc.json
+12
.prettierrc.json
-16
.zed/settings.json
-16
.zed/settings.json
···
1
-
// Folder-specific settings
2
-
//
3
-
// For a full list of overridable settings, and general information on folder-specific settings,
4
-
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
5
-
{
6
-
"languages": {
7
-
"HTML": {
8
-
"prettier": {
9
-
"format_on_save": false,
10
-
"allowed": true,
11
-
"parser": "go-template",
12
-
"plugins": ["prettier-plugin-go-template"]
13
-
}
14
-
}
15
-
}
16
-
}
+13
-27
appview/pages/templates/repo/fragments/artifact.html
+13
-27
appview/pages/templates/repo/fragments/artifact.html
···
1
1
{{ define "repo/fragments/artifact" }}
2
-
{{ $unique := .Artifact.BlobCid.String }}
3
-
<div
4
-
id="artifact-{{ $unique }}"
5
-
class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
6
-
<div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]">
7
-
{{ i "box" "w-4 h-4" }}
8
-
<a
9
-
href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}"
10
-
class="no-underline hover:no-underline">
11
-
{{ .Artifact.Name }}
12
-
</a>
13
-
<span class="text-gray-500 dark:text-gray-400 pl-2 text-sm">
14
-
{{ byteFmt .Artifact.Size }}
15
-
</span>
16
-
</div>
2
+
{{ $unique := .Artifact.BlobCid.String }}
3
+
<div id="artifact-{{ $unique }}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
4
+
<div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]">
5
+
{{ i "box" "w-4 h-4" }}
6
+
<a href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}" class="no-underline hover:no-underline">
7
+
{{ .Artifact.Name }}
8
+
</a>
9
+
<span class="text-gray-500 dark:text-gray-400 pl-2 text-sm">{{ byteFmt .Artifact.Size }}</span>
10
+
</div>
17
11
18
-
<div
19
-
id="right-side"
20
-
class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm">
21
-
<span class="hidden md:inline">
22
-
{{ template "repo/fragments/time" .Artifact.CreatedAt }}
23
-
</span>
24
-
<span class=" md:hidden">
25
-
{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}
26
-
</span>
12
+
<div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm">
13
+
<span class="hidden md:inline">{{ template "repo/fragments/time" .Artifact.CreatedAt }}</span>
14
+
<span class=" md:hidden">{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}</span>
27
15
28
16
<span class="select-none after:content-['·'] hidden md:inline"></span>
29
-
<span class="truncate max-w-[100px] hidden md:inline">
30
-
{{ .Artifact.MimeType }}
31
-
</span>
17
+
<span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
32
18
33
19
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }}
34
20
<button
+6
-14
appview/pages/templates/repo/fragments/diffOpts.html
+6
-14
appview/pages/templates/repo/fragments/diffOpts.html
···
1
1
{{ define "repo/fragments/diffOpts" }}
2
-
<section
3
-
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">
2
+
<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">
4
3
<strong class="text-sm uppercase dark:text-gray-200">options</strong>
5
4
{{ $active := "unified" }}
6
5
{{ if .Split }}
7
6
{{ $active = "split" }}
8
7
{{ end }}
9
8
{{ $values := list "unified" "split" }}
10
-
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }}
11
-
{{ end }}
9
+
{{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }}
12
10
</section>
13
11
{{ end }}
14
12
···
16
14
{{ $name := .Name }}
17
15
{{ $all := .Values }}
18
16
{{ $active := .Active }}
19
-
<div
20
-
class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden">
17
+
<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">
21
18
{{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }}
22
19
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }}
23
20
{{ range $index, $value := $all }}
24
21
{{ $isActive := eq $value $active }}
25
-
<a
26
-
href="?{{ $name }}={{ $value }}"
27
-
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }}
28
-
{{ $activeTab }}
29
-
{{ else }}
30
-
{{ $inactiveTab }}
31
-
{{ end }}">
32
-
{{ $value }}
22
+
<a href="?{{ $name }}={{ $value }}"
23
+
class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
24
+
{{ $value }}
33
25
</a>
34
26
{{ end }}
35
27
</div>
+5
-16
appview/pages/templates/repo/fragments/diffStatPill.html
+5
-16
appview/pages/templates/repo/fragments/diffStatPill.html
···
1
1
{{ define "repo/fragments/diffStatPill" }}
2
2
<div class="flex items-center font-mono text-sm">
3
3
{{ if and .Insertions .Deletions }}
4
-
<span
5
-
class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">
6
-
+{{ .Insertions }}
7
-
</span>
8
-
<span
9
-
class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">
10
-
-{{ .Deletions }}
11
-
</span>
4
+
<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>
5
+
<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>
12
6
{{ else if .Insertions }}
13
-
<span
14
-
class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">
15
-
+{{ .Insertions }}
16
-
</span>
7
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
17
8
{{ else if .Deletions }}
18
-
<span
19
-
class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">
20
-
-{{ .Deletions }}
21
-
</span>
9
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
22
10
{{ end }}
23
11
</div>
24
12
{{ end }}
13
+
+24
-18
appview/pages/templates/repo/fragments/reactionsPopUp.html
+24
-18
appview/pages/templates/repo/fragments/reactionsPopUp.html
···
1
1
{{ define "repo/fragments/reactionsPopUp" }}
2
-
<details id="reactionsPopUp" class="relative inline-block">
3
-
<summary
4
-
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
2
+
<details
3
+
id="reactionsPopUp"
4
+
class="relative inline-block"
5
+
>
6
+
<summary
7
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
5
8
hover:bg-gray-50
6
9
hover:border-gray-300
7
10
dark:hover:bg-gray-700
8
11
dark:hover:border-gray-600
9
-
cursor-pointer list-none">
10
-
{{ i "smile" "size-4" }}
11
-
</summary>
12
-
<div
13
-
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">
14
-
{{ range $kind := . }}
15
-
<button
16
-
id="reactBtn-{{ $kind }}"
17
-
class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700"
18
-
hx-on:click="this.parentElement.parentElement.removeAttribute('open')">
19
-
{{ $kind }}
20
-
</button>
21
-
{{ end }}
22
-
</div>
23
-
</details>
12
+
cursor-pointer list-none"
13
+
>
14
+
{{ i "smile" "size-4" }}
15
+
</summary>
16
+
<div
17
+
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"
18
+
>
19
+
{{ range $kind := . }}
20
+
<button
21
+
id="reactBtn-{{ $kind }}"
22
+
class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700"
23
+
hx-on:click="this.parentElement.parentElement.removeAttribute('open')"
24
+
>
25
+
{{ $kind }}
26
+
</button>
27
+
{{ end }}
28
+
</div>
29
+
</details>
24
30
{{ end }}
+23
-22
appview/pages/templates/repo/fragments/repoStar.html
+23
-22
appview/pages/templates/repo/fragments/repoStar.html
···
1
1
{{ define "repo/fragments/repoStar" }}
2
-
<button
3
-
id="starBtn"
4
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
5
-
{{ if .IsStarred }}
6
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
7
-
{{ else }}
8
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
9
-
{{ end }}
2
+
<button
3
+
id="starBtn"
4
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
5
+
{{ if .IsStarred }}
6
+
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
7
+
{{ else }}
8
+
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
9
+
{{ end }}
10
10
11
-
hx-trigger="click"
12
-
hx-target="this"
13
-
hx-swap="outerHTML"
14
-
hx-disabled-elt="#starBtn">
15
-
{{ if .IsStarred }}
16
-
{{ i "star" "w-4 h-4 fill-current" }}
17
-
{{ else }}
18
-
{{ i "star" "w-4 h-4" }}
19
-
{{ end }}
20
-
<span class="text-sm">
21
-
{{ .Stats.StarCount }}
22
-
</span>
23
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
24
-
</button>
11
+
hx-trigger="click"
12
+
hx-target="this"
13
+
hx-swap="outerHTML"
14
+
hx-disabled-elt="#starBtn"
15
+
>
16
+
{{ if .IsStarred }}
17
+
{{ i "star" "w-4 h-4 fill-current" }}
18
+
{{ else }}
19
+
{{ i "star" "w-4 h-4" }}
20
+
{{ end }}
21
+
<span class="text-sm">
22
+
{{ .Stats.StarCount }}
23
+
</span>
24
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
25
+
</button>
25
26
{{ end }}
+58
-113
appview/pages/templates/repo/fragments/splitDiff.html
+58
-113
appview/pages/templates/repo/fragments/splitDiff.html
···
1
1
{{ define "repo/fragments/splitDiff" }}
2
-
{{ $name := .Id }}
3
-
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
-
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
-
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
-
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
-
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
-
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
-
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
-
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
-
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
-
<pre
14
-
class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}
15
-
<div
16
-
class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">
17
-
···
18
-
</div>
19
-
{{- range .LeftLines -}}
20
-
{{- if .IsEmpty -}}
21
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
22
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
23
-
<span aria-hidden="true" class="invisible">
24
-
{{ .LineNumber }}
25
-
</span>
26
-
</div>
27
-
<div class="{{ $opStyle }}">
28
-
<span aria-hidden="true" class="invisible">{{ .Op.String }}</span>
29
-
</div>
30
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
31
-
</div>
32
-
{{- else if eq .Op.String "-" -}}
33
-
<div
34
-
class="{{ $delStyle }} {{ $containerStyle }}"
35
-
id="{{ $name }}-O{{ .LineNumber }}">
36
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
37
-
<a
38
-
class="{{ $linkStyle }}"
39
-
href="#{{ $name }}-O{{ .LineNumber }}">
40
-
{{ .LineNumber }}
41
-
</a>
42
-
</div>
43
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
44
-
<div class="px-2">{{ .Content }}</div>
45
-
</div>
46
-
{{- else if eq .Op.String " " -}}
47
-
<div
48
-
class="{{ $ctxStyle }} {{ $containerStyle }}"
49
-
id="{{ $name }}-O{{ .LineNumber }}">
50
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
51
-
<a
52
-
class="{{ $linkStyle }}"
53
-
href="#{{ $name }}-O{{ .LineNumber }}">
54
-
{{ .LineNumber }}
55
-
</a>
56
-
</div>
57
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
58
-
<div class="px-2">{{ .Content }}</div>
59
-
</div>
60
-
{{- end -}}
61
-
{{- end -}}
62
-
{{- end -}}
63
-
</div></div></pre>
2
+
{{ $name := .Id }}
3
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
+
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
+
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
+
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
+
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
+
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
+
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
+
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
+
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
+
<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">···</div>
14
+
{{- range .LeftLines -}}
15
+
{{- if .IsEmpty -}}
16
+
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
17
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
18
+
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
19
+
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
20
+
</div>
21
+
{{- else if eq .Op.String "-" -}}
22
+
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
+
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
24
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
25
+
<div class="px-2">{{ .Content }}</div>
26
+
</div>
27
+
{{- else if eq .Op.String " " -}}
28
+
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
+
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
30
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
31
+
<div class="px-2">{{ .Content }}</div>
32
+
</div>
33
+
{{- end -}}
34
+
{{- end -}}
35
+
{{- end -}}</div></div></pre>
64
36
65
-
<pre
66
-
class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}
67
-
<div
68
-
class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">
69
-
···
70
-
</div>
71
-
{{- range .RightLines -}}
72
-
{{- if .IsEmpty -}}
73
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
74
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
75
-
<span aria-hidden="true" class="invisible">
76
-
{{ .LineNumber }}
77
-
</span>
78
-
</div>
79
-
<div class="{{ $opStyle }}">
80
-
<span aria-hidden="true" class="invisible">{{ .Op.String }}</span>
81
-
</div>
82
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
83
-
</div>
84
-
{{- else if eq .Op.String "+" -}}
85
-
<div
86
-
class="{{ $addStyle }} {{ $containerStyle }}"
87
-
id="{{ $name }}-N{{ .LineNumber }}">
88
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
89
-
<a
90
-
class="{{ $linkStyle }}"
91
-
href="#{{ $name }}-N{{ .LineNumber }}">
92
-
{{ .LineNumber }}
93
-
</a>
94
-
</div>
95
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
96
-
<div class="px-2">{{ .Content }}</div>
97
-
</div>
98
-
{{- else if eq .Op.String " " -}}
99
-
<div
100
-
class="{{ $ctxStyle }} {{ $containerStyle }}"
101
-
id="{{ $name }}-N{{ .LineNumber }}">
102
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}">
103
-
<a
104
-
class="{{ $linkStyle }}"
105
-
href="#{{ $name }}-N{{ .LineNumber }}">
106
-
{{ .LineNumber }}
107
-
</a>
108
-
</div>
109
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
110
-
<div class="px-2">{{ .Content }}</div>
111
-
</div>
112
-
{{- end -}}
113
-
{{- end -}}
114
-
{{- end -}}</div></div></pre>
115
-
</div>
37
+
<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">···</div>
38
+
{{- range .RightLines -}}
39
+
{{- if .IsEmpty -}}
40
+
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
41
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
42
+
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
43
+
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
44
+
</div>
45
+
{{- else if eq .Op.String "+" -}}
46
+
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
48
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
49
+
<div class="px-2" >{{ .Content }}</div>
50
+
</div>
51
+
{{- else if eq .Op.String " " -}}
52
+
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
54
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
55
+
<div class="px-2">{{ .Content }}</div>
56
+
</div>
57
+
{{- end -}}
58
+
{{- end -}}
59
+
{{- end -}}</div></div></pre>
60
+
</div>
116
61
{{ end }}
+45
-79
appview/pages/templates/repo/fragments/unifiedDiff.html
+45
-79
appview/pages/templates/repo/fragments/unifiedDiff.html
···
1
1
{{ define "repo/fragments/unifiedDiff" }}
2
-
{{ $name := .Id }}
3
-
<pre
4
-
class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}
5
-
<div
6
-
class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">
7
-
···
8
-
</div>
9
-
{{- $oldStart := .OldPosition -}}
10
-
{{- $newStart := .NewPosition -}}
11
-
{{- $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" -}}
12
-
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
13
-
{{- $lineNrSepStyle1 := "" -}}
14
-
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
15
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
16
-
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
17
-
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
18
-
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
19
-
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
20
-
{{- range .Lines -}}
21
-
{{- if eq .Op.String "+" -}}
22
-
<div
23
-
class="{{ $addStyle }} {{ $containerStyle }}"
24
-
id="{{ $name }}-N{{ $newStart }}">
25
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}">
26
-
<span aria-hidden="true" class="invisible">{{ $newStart }}</span>
27
-
</div>
28
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}">
29
-
<a class="{{ $linkStyle }}" href="#{{ $name }}-N{{ $newStart }}">
30
-
{{ $newStart }}
31
-
</a>
32
-
</div>
33
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
34
-
<div class="px-2">{{ .Line }}</div>
35
-
</div>
36
-
{{- $newStart = add64 $newStart 1 -}}
37
-
{{- end -}}
38
-
{{- if eq .Op.String "-" -}}
39
-
<div
40
-
class="{{ $delStyle }} {{ $containerStyle }}"
41
-
id="{{ $name }}-O{{ $oldStart }}">
42
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}">
43
-
<a class="{{ $linkStyle }}" href="#{{ $name }}-O{{ $oldStart }}">
44
-
{{ $oldStart }}
45
-
</a>
46
-
</div>
47
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}">
48
-
<span aria-hidden="true" class="invisible">{{ $oldStart }}</span>
49
-
</div>
50
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
51
-
<div class="px-2">{{ .Line }}</div>
52
-
</div>
53
-
{{- $oldStart = add64 $oldStart 1 -}}
54
-
{{- end -}}
55
-
{{- if eq .Op.String " " -}}
56
-
<div
57
-
class="{{ $ctxStyle }} {{ $containerStyle }}"
58
-
id="{{ $name }}-O{{ $oldStart }}-N{{ $newStart }}">
59
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle1 }}">
60
-
<a
61
-
class="{{ $linkStyle }}"
62
-
href="#{{ $name }}-O{{ $oldStart }}-N{{ $newStart }}">
63
-
{{ $oldStart }}
64
-
</a>
65
-
</div>
66
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle2 }}">
67
-
<a
68
-
class="{{ $linkStyle }}"
69
-
href="#{{ $name }}-O{{ $oldStart }}-N{{ $newStart }}">
70
-
{{ $newStart }}
71
-
</a>
72
-
</div>
73
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
74
-
<div class="px-2">{{ .Line }}</div>
75
-
</div>
76
-
{{- $newStart = add64 $newStart 1 -}}
77
-
{{- $oldStart = add64 $oldStart 1 -}}
78
-
{{- end -}}
79
-
{{- end -}}
80
-
{{- end -}}</div></div></pre>
2
+
{{ $name := .Id }}
3
+
<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">···</div>
4
+
{{- $oldStart := .OldPosition -}}
5
+
{{- $newStart := .NewPosition -}}
6
+
{{- $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" -}}
7
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
+
{{- $lineNrSepStyle1 := "" -}}
9
+
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
+
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
+
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
+
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
+
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
+
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
+
{{- range .Lines -}}
16
+
{{- if eq .Op.String "+" -}}
17
+
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
19
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
20
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
21
+
<div class="px-2">{{ .Line }}</div>
22
+
</div>
23
+
{{- $newStart = add64 $newStart 1 -}}
24
+
{{- end -}}
25
+
{{- if eq .Op.String "-" -}}
26
+
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
28
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
29
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
30
+
<div class="px-2">{{ .Line }}</div>
31
+
</div>
32
+
{{- $oldStart = add64 $oldStart 1 -}}
33
+
{{- end -}}
34
+
{{- if eq .Op.String " " -}}
35
+
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div>
37
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div>
38
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
39
+
<div class="px-2">{{ .Line }}</div>
40
+
</div>
41
+
{{- $newStart = add64 $newStart 1 -}}
42
+
{{- $oldStart = add64 $oldStart 1 -}}
43
+
{{- end -}}
44
+
{{- end -}}
45
+
{{- end -}}</div></div></pre>
81
46
{{ end }}
47
+
+12
-21
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+12
-21
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
1
1
{{ define "repo/pipelines/fragments/logBlock" }}
2
-
<div id="lines" hx-swap-oob="beforeend">
3
-
<details
4
-
id="step-{{ .Id }}"
5
-
{{ if not .Collapsed }}open{{ end }}
6
-
class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
7
-
<summary
8
-
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">
9
-
<div class="group-open:hidden flex items-center gap-1">
10
-
{{ i "chevron-right" "w-4 h-4" }}
11
-
{{ .Name }}
12
-
</div>
13
-
<div class="hidden group-open:flex items-center gap-1">
14
-
{{ i "chevron-down" "w-4 h-4" }}
15
-
{{ .Name }}
16
-
</div>
17
-
</summary>
18
-
<div class="font-mono whitespace-pre overflow-x-auto px-2">
19
-
<div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div>
20
-
<div id="step-body-{{ .Id }}"></div>
2
+
<div id="lines" hx-swap-oob="beforeend">
3
+
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
4
+
<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">
5
+
<div class="group-open:hidden flex items-center gap-1">
6
+
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
21
7
</div>
22
-
</details>
23
-
</div>
8
+
<div class="hidden group-open:flex items-center gap-1">
9
+
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
+
</div>
11
+
</summary>
12
+
<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>
13
+
</details>
14
+
</div>
24
15
{{ end }}
+1
appview/pages/templates/repo/pulls/fragments/summarizedPullState.html
+1
appview/pages/templates/repo/pulls/fragments/summarizedPullState.html
+146
-136
appview/pages/templates/repo/pulls/new.html
+146
-136
appview/pages/templates/repo/pulls/new.html
···
1
1
{{ define "title" }}new pull · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "repoContent" }}
4
-
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
5
-
Create new pull request
6
-
</h2>
7
-
8
-
<form
9
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/new"
10
-
hx-indicator="#create-pull-spinner"
11
-
hx-swap="none">
12
-
<div class="flex flex-col gap-6">
13
-
<div class="flex gap-2 items-center">
14
-
<p>First, choose a target branch on {{ .RepoInfo.FullName }}:</p>
15
-
<div>
16
-
<select
17
-
required
18
-
name="targetBranch"
19
-
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600">
20
-
<option disabled selected>target branch</option>
21
-
22
-
{{ range .Branches }}
23
-
{{ $preset := false }}
24
-
{{ if $.TargetBranch }}
25
-
{{ $preset = eq .Reference.Name $.TargetBranch }}
26
-
{{ else }}
27
-
{{ $preset = .IsDefault }}
28
-
{{ end }}
29
-
30
-
31
-
<option
32
-
value="{{ .Reference.Name }}"
33
-
class="py-1"
34
-
{{ if $preset }}selected{{ end }}>
35
-
{{ .Reference.Name }}
36
-
</option>
37
-
{{ end }}
38
-
</select>
39
-
</div>
40
-
</div>
41
-
42
-
<div class="flex flex-col gap-2">
43
-
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
44
-
Choose pull strategy
45
-
</h2>
46
-
<nav class="flex space-x-4 items-center">
47
-
<button
48
-
type="button"
49
-
class="btn"
50
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
51
-
hx-target="#patch-strategy"
52
-
hx-swap="innerHTML">
53
-
paste patch
54
-
</button>
55
-
56
-
{{ if .RepoInfo.Roles.IsPushAllowed }}
57
-
<span class="text-sm text-gray-500 dark:text-gray-400">or</span>
58
-
<button
59
-
type="button"
60
-
class="btn"
61
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
62
-
hx-target="#patch-strategy"
63
-
hx-swap="innerHTML">
64
-
compare branches
65
-
</button>
66
-
{{ end }}
67
-
68
-
69
-
<span class="text-sm text-gray-500 dark:text-gray-400">or</span>
70
-
<script>
71
-
function getQueryParams() {
72
-
return Object.fromEntries(
73
-
new URLSearchParams(window.location.search),
74
-
);
75
-
}
76
-
</script>
77
-
<!--
4
+
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
5
+
Create new pull request
6
+
</h2>
7
+
8
+
<form
9
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/new"
10
+
hx-indicator="#create-pull-spinner"
11
+
hx-swap="none"
12
+
>
13
+
<div class="flex flex-col gap-6">
14
+
<div class="flex gap-2 items-center">
15
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}:</p>
16
+
<div>
17
+
<select
18
+
required
19
+
name="targetBranch"
20
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
21
+
>
22
+
<option disabled selected>target branch</option>
23
+
24
+
25
+
{{ range .Branches }}
26
+
27
+
{{ $preset := false }}
28
+
{{ if $.TargetBranch }}
29
+
{{ $preset = eq .Reference.Name $.TargetBranch }}
30
+
{{ else }}
31
+
{{ $preset = .IsDefault }}
32
+
{{ end }}
33
+
34
+
<option value="{{ .Reference.Name }}" class="py-1" {{if $preset}}selected{{end}}>
35
+
{{ .Reference.Name }}
36
+
</option>
37
+
{{ end }}
38
+
</select>
39
+
</div>
40
+
</div>
41
+
42
+
<div class="flex flex-col gap-2">
43
+
<h2 class="font-bold text-sm mb-4 uppercase dark:text-white">
44
+
Choose pull strategy
45
+
</h2>
46
+
<nav class="flex space-x-4 items-center">
47
+
<button
48
+
type="button"
49
+
class="btn"
50
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
51
+
hx-target="#patch-strategy"
52
+
hx-swap="innerHTML"
53
+
>
54
+
paste patch
55
+
</button>
56
+
57
+
{{ if .RepoInfo.Roles.IsPushAllowed }}
58
+
<span class="text-sm text-gray-500 dark:text-gray-400">
59
+
or
60
+
</span>
61
+
<button
62
+
type="button"
63
+
class="btn"
64
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
65
+
hx-target="#patch-strategy"
66
+
hx-swap="innerHTML"
67
+
>
68
+
compare branches
69
+
</button>
70
+
{{ end }}
71
+
72
+
73
+
<span class="text-sm text-gray-500 dark:text-gray-400">
74
+
or
75
+
</span>
76
+
<script>
77
+
function getQueryParams() {
78
+
return Object.fromEntries(new URLSearchParams(window.location.search));
79
+
}
80
+
</script>
81
+
<!--
78
82
since compare-forks need the server to load forks, we
79
83
hx-get this button; unlike simply loading the pullCompareForks template
80
84
as we do for the rest of the gang below. the hx-vals thing just populates
81
85
the query params so the forks page gets it.
82
86
-->
83
-
<button
84
-
type="button"
85
-
class="btn"
86
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
87
-
hx-target="#patch-strategy"
88
-
hx-swap="innerHTML"
89
-
{{ if eq .Strategy "fork" }}
90
-
hx-trigger="click, load" hx-vals='js:{...getQueryParams()}'
91
-
{{ end }}>
92
-
compare forks
93
-
</button>
94
-
</nav>
95
-
<section id="patch-strategy" class="flex flex-col gap-2">
96
-
{{ if eq .Strategy "patch" }}
97
-
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
98
-
{{ else if eq .Strategy "branch" }}
99
-
{{ template "repo/pulls/fragments/pullCompareBranches" . }}
100
-
{{ else }}
101
-
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
102
-
{{ end }}
103
-
</section>
104
-
105
-
<div id="patch-error" class="error dark:text-red-300"></div>
106
-
</div>
107
-
108
-
<div>
109
-
<label for="title" class="dark:text-white">write a title</label>
110
-
111
-
<input
112
-
type="text"
113
-
name="title"
114
-
id="title"
115
-
value="{{ .Title }}"
116
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600"
117
-
placeholder="One-line summary of your change." />
118
-
</div>
119
-
120
-
<div>
121
-
<label for="body" class="dark:text-white">add a description</label>
122
-
123
-
<textarea
124
-
name="body"
125
-
id="body"
126
-
rows="6"
127
-
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
128
-
placeholder="Describe your change. Markdown is supported.">
129
-
{{ .Body }}</textarea
130
-
>
131
-
</div>
132
-
133
-
<div class="flex justify-start items-center gap-2 mt-4">
134
-
<button type="submit" class="btn-create flex items-center gap-2">
135
-
{{ i "git-pull-request-create" "w-4 h-4" }}
136
-
create pull
137
-
<span id="create-pull-spinner" class="group">
138
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
139
-
</span>
140
-
</button>
141
-
</div>
142
-
</div>
143
-
<div id="pull" class="error dark:text-red-300"></div>
144
-
</form>
87
+
<button
88
+
type="button"
89
+
class="btn"
90
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
91
+
hx-target="#patch-strategy"
92
+
hx-swap="innerHTML"
93
+
{{ if eq .Strategy "fork" }}
94
+
hx-trigger="click, load"
95
+
hx-vals='js:{...getQueryParams()}'
96
+
{{ end }}
97
+
>
98
+
compare forks
99
+
</button>
100
+
101
+
102
+
</nav>
103
+
<section id="patch-strategy" class="flex flex-col gap-2">
104
+
{{ if eq .Strategy "patch" }}
105
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
106
+
{{ else if eq .Strategy "branch" }}
107
+
{{ template "repo/pulls/fragments/pullCompareBranches" . }}
108
+
{{ else }}
109
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
110
+
{{ end }}
111
+
</section>
112
+
113
+
<div id="patch-error" class="error dark:text-red-300"></div>
114
+
</div>
115
+
116
+
<div>
117
+
<label for="title" class="dark:text-white">write a title</label>
118
+
119
+
<input
120
+
type="text"
121
+
name="title"
122
+
id="title"
123
+
value="{{ .Title }}"
124
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600"
125
+
placeholder="One-line summary of your change."
126
+
/>
127
+
</div>
128
+
129
+
<div>
130
+
<label for="body" class="dark:text-white"
131
+
>add a description</label
132
+
>
133
+
134
+
<textarea
135
+
name="body"
136
+
id="body"
137
+
rows="6"
138
+
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
139
+
placeholder="Describe your change. Markdown is supported."
140
+
>{{ .Body }}</textarea>
141
+
</div>
142
+
143
+
<div class="flex justify-start items-center gap-2 mt-4">
144
+
<button type="submit" class="btn-create flex items-center gap-2">
145
+
{{ i "git-pull-request-create" "w-4 h-4" }}
146
+
create pull
147
+
<span id="create-pull-spinner" class="group">
148
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
149
+
</span>
150
+
</button>
151
+
</div>
152
+
</div>
153
+
<div id="pull" class="error dark:text-red-300"></div>
154
+
</form>
145
155
{{ end }}
+8
-15
appview/pages/templates/repo/settings/fragments/secretListing.html
+8
-15
appview/pages/templates/repo/settings/fragments/secretListing.html
···
1
1
{{ define "repo/settings/fragments/secretListing" }}
2
2
{{ $root := index . 0 }}
3
3
{{ $secret := index . 1 }}
4
-
<div
5
-
id="secret-{{ $secret.Key }}"
6
-
class="flex items-center justify-between p-2">
7
-
<div
8
-
class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
4
+
<div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2">
5
+
<div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
9
6
<span class="font-mono">
10
7
{{ $secret.Key }}
11
8
</span>
12
-
<div
13
-
class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
9
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
14
10
<span>added by</span>
15
-
<span>
16
-
{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}
17
-
</span>
11
+
<span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span>
18
12
<span class="before:content-['·'] before:select-none"></span>
19
-
<span>
20
-
{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}
21
-
</span>
13
+
<span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span>
22
14
</div>
23
15
</div>
24
16
<button
···
27
19
hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets"
28
20
hx-swap="none"
29
21
hx-vals='{"key": "{{ $secret.Key }}"}'
30
-
hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?">
22
+
hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?"
23
+
>
31
24
{{ i "trash-2" "w-5 h-5" }}
32
-
<span class="hidden md:inline">delete</span>
25
+
<span class="hidden md:inline">delete</span>
33
26
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
34
27
</button>
35
28
</div>
+84
-119
appview/pages/templates/timeline.html
+84
-119
appview/pages/templates/timeline.html
···
1
1
{{ define "title" }}timeline{{ end }}
2
2
3
3
{{ define "extrameta" }}
4
-
<meta property="og:title" content="timeline · tangled" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh" />
7
-
<meta property="og:description" content="see what's tangling" />
4
+
<meta property="og:title" content="timeline · tangled" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.sh" />
7
+
<meta property="og:description" content="see what's tangling" />
8
8
{{ end }}
9
9
10
10
{{ define "topbar" }}
···
12
12
{{ end }}
13
13
14
14
{{ define "content" }}
15
-
{{ with .LoggedInUser }}
16
-
{{ block "timeline" $ }}{{ end }}
17
-
{{ else }}
18
-
{{ block "hero" $ }}{{ end }}
19
-
{{ block "timeline" $ }}{{ end }}
20
-
{{ end }}
15
+
{{ with .LoggedInUser }}
16
+
{{ block "timeline" $ }}{{ end }}
17
+
{{ else }}
18
+
{{ block "hero" $ }}{{ end }}
19
+
{{ block "timeline" $ }}{{ end }}
20
+
{{ end }}
21
21
{{ end }}
22
22
23
23
{{ define "hero" }}
24
-
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
25
-
<div class="font-bold text-4xl">
26
-
tightly-knit
27
-
<br />
28
-
social coding.
29
-
</div>
24
+
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
25
+
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
30
26
31
-
<p class="text-lg">
32
-
tangled is new social-enabled git collaboration platform built on
33
-
<a class="underline" href="https://atproto.com/">atproto</a>
34
-
.
35
-
</p>
36
-
<p class="text-lg">
37
-
we envision a place where developers have complete ownership of their
38
-
code, open source communities can freely self-govern and most importantly,
39
-
coding can be social and fun again.
40
-
</p>
27
+
<p class="text-lg">
28
+
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
29
+
</p>
30
+
<p class="text-lg">
31
+
we envision a place where developers have complete ownership of their
32
+
code, open source communities can freely self-govern and most
33
+
importantly, coding can be social and fun again.
34
+
</p>
41
35
42
-
<div class="flex gap-6 items-center">
43
-
<a href="/signup" class="no-underline hover:no-underline ">
44
-
<button class="btn-create flex gap-2 px-4 items-center">
45
-
join now
46
-
{{ i "arrow-right" "size-4" }}
47
-
</button>
48
-
</a>
36
+
<div class="flex gap-6 items-center">
37
+
<a href="/signup" class="no-underline hover:no-underline ">
38
+
<button class="btn-create flex gap-2 px-4 items-center">
39
+
join now {{ i "arrow-right" "size-4" }}
40
+
</button>
41
+
</a>
42
+
</div>
49
43
</div>
50
-
</div>
51
44
{{ end }}
52
45
53
46
{{ define "timeline" }}
54
-
<div>
55
-
<div class="p-6">
56
-
<p class="text-xl font-bold dark:text-white">Timeline</p>
57
-
</div>
47
+
<div>
48
+
<div class="p-6">
49
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
50
+
</div>
58
51
59
-
<div class="flex flex-col gap-4">
60
-
{{ range $i, $e := .Timeline }}
61
-
<div class="relative">
62
-
{{ if ne $i 0 }}
63
-
<div
64
-
class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
65
-
{{ end }}
66
-
{{ with $e }}
67
-
<div
68
-
class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
69
-
{{ if .Repo }}
70
-
{{ block "repoEvent" (list $ .Repo .Source) }}{{ end }}
71
-
{{ else if .Star }}
72
-
{{ block "starEvent" (list $ .Star) }}{{ end }}
73
-
{{ else if .Follow }}
74
-
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }}
75
-
{{ end }}
52
+
<div class="flex flex-col gap-4">
53
+
{{ range $i, $e := .Timeline }}
54
+
<div class="relative">
55
+
{{ if ne $i 0 }}
56
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
57
+
{{ end }}
58
+
{{ with $e }}
59
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
60
+
{{ if .Repo }}
61
+
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
62
+
{{ else if .Star }}
63
+
{{ block "starEvent" (list $ .Star) }} {{ end }}
64
+
{{ else if .Follow }}
65
+
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
66
+
{{ end }}
67
+
</div>
76
68
{{ end }}
77
69
</div>
78
70
{{ end }}
79
71
</div>
80
-
{{ end }}
81
72
</div>
82
-
</div>
83
73
{{ end }}
84
74
85
75
{{ define "repoEvent" }}
···
87
77
{{ $repo := index . 1 }}
88
78
{{ $source := index . 2 }}
89
79
{{ $userHandle := resolve $repo.Did }}
90
-
<div
91
-
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">
92
-
{{ template "user/fragments/picHandleLink" $repo.Did }}
93
-
{{ with $source }}
94
-
{{ $sourceDid := resolve .Did }}
95
-
forked
96
-
<a
97
-
href="/{{ $sourceDid }}/{{ .Name }}"
98
-
class="no-underline hover:underline">
99
-
{{ $sourceDid }}/{{ .Name }}
100
-
</a>
101
-
to
102
-
<a
103
-
href="/{{ $userHandle }}/{{ $repo.Name }}"
104
-
class="no-underline hover:underline">
105
-
{{ $repo.Name }}
106
-
</a>
107
-
{{ else }}
108
-
created
109
-
<a
110
-
href="/{{ $userHandle }}/{{ $repo.Name }}"
111
-
class="no-underline hover:underline">
112
-
{{ $repo.Name }}
113
-
</a>
114
-
{{ end }}
115
-
<span class="text-gray-700 dark:text-gray-400 text-xs">
116
-
{{ template "repo/fragments/time" $repo.Created }}
117
-
</span>
118
-
</div>
80
+
<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">
81
+
{{ template "user/fragments/picHandleLink" $repo.Did }}
82
+
{{ with $source }}
83
+
{{ $sourceDid := resolve .Did }}
84
+
forked
85
+
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
86
+
{{ $sourceDid }}/{{ .Name }}
87
+
</a>
88
+
to
89
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
90
+
{{ else }}
91
+
created
92
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
93
+
{{ $repo.Name }}
94
+
</a>
95
+
{{ end }}
96
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
97
+
</div>
119
98
{{ with $repo }}
120
99
{{ template "user/fragments/repoCard" (list $root . true) }}
121
100
{{ end }}
···
127
106
{{ with $star }}
128
107
{{ $starrerHandle := resolve .StarredByDid }}
129
108
{{ $repoOwnerHandle := resolve .Repo.Did }}
130
-
<div
131
-
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">
132
-
{{ template "user/fragments/picHandleLink" $starrerHandle }}
133
-
starred
134
-
<a
135
-
href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}"
136
-
class="no-underline hover:underline">
137
-
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
138
-
</a>
139
-
<span class="text-gray-700 dark:text-gray-400 text-xs">
140
-
{{ template "repo/fragments/time" .Created }}
141
-
</span>
109
+
<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">
110
+
{{ template "user/fragments/picHandleLink" $starrerHandle }}
111
+
starred
112
+
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
113
+
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
114
+
</a>
115
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
142
116
</div>
143
117
{{ with .Repo }}
144
118
{{ template "user/fragments/repoCard" (list $root . true) }}
···
146
120
{{ end }}
147
121
{{ end }}
148
122
123
+
149
124
{{ define "followEvent" }}
150
125
{{ $root := index . 0 }}
151
126
{{ $follow := index . 1 }}
···
154
129
155
130
{{ $userHandle := resolve $follow.UserDid }}
156
131
{{ $subjectHandle := resolve $follow.SubjectDid }}
157
-
<div
158
-
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">
159
-
{{ template "user/fragments/picHandleLink" $userHandle }}
160
-
followed
161
-
{{ template "user/fragments/picHandleLink" $subjectHandle }}
162
-
<span class="text-gray-700 dark:text-gray-400 text-xs">
163
-
{{ template "repo/fragments/time" $follow.FollowedAt }}
164
-
</span>
132
+
<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">
133
+
{{ template "user/fragments/picHandleLink" $userHandle }}
134
+
followed
135
+
{{ template "user/fragments/picHandleLink" $subjectHandle }}
136
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
165
137
</div>
166
-
<div
167
-
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
138
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
168
139
<div class="flex-shrink-0 max-h-full w-24 h-24">
169
-
<img
170
-
class="object-cover rounded-full p-2"
171
-
src="{{ fullAvatar $subjectHandle }}" />
140
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
172
141
</div>
173
142
174
143
<div class="flex-1 min-h-0 justify-around flex flex-col">
175
144
<a href="/{{ $subjectHandle }}">
176
-
<span
177
-
class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
178
-
{{ $subjectHandle | truncateAt30 }}
179
-
</span>
145
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
180
146
</a>
181
147
{{ with $profile }}
182
148
{{ with .Description }}
183
-
<p class="text-sm pb-2 md:pb-2">{{ . }}</p>
149
+
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
184
150
{{ end }}
185
151
{{ end }}
186
152
{{ with $stat }}
187
-
<div
188
-
class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
153
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
189
154
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
190
155
<span id="followers">{{ .Followers }} followers</span>
191
156
<span class="select-none after:content-['·']"></span>
192
-
<span id="following">{{ .Following }} following</span>
157
+
<span id="following">{{ .Following }} following</span>
193
158
</div>
194
159
{{ end }}
195
160
</div>
+29
-48
appview/pages/templates/user/fragments/editPins.html
+29
-48
appview/pages/templates/user/fragments/editPins.html
···
1
1
{{ define "user/fragments/editPins" }}
2
2
{{ $profile := .Profile }}
3
-
<form
4
-
hx-post="/profile/pins"
5
-
hx-disabled-elt="#save-btn,#cancel-btn"
6
-
hx-swap="none"
7
-
hx-indicator="#spinner">
8
-
<div class="flex items-center justify-between mb-2">
9
-
<p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p>
10
-
<div class="flex items-center gap-2">
11
-
<button
12
-
id="save-btn"
13
-
type="submit"
14
-
class="btn px-2 flex items-center gap-2 no-underline text-sm">
15
-
{{ i "check" "w-3 h-3" }} save
16
-
<span id="spinner" class="group">
17
-
{{ i "loader-circle" "w-3 h-3 animate-spin hidden group-[.htmx-request]:inline" }}
18
-
</span>
19
-
</button>
20
-
<a
21
-
href="/{{ .LoggedInUser.Did }}"
22
-
class="w-full no-underline hover:no-underline">
23
-
<button
24
-
id="cancel-btn"
25
-
type="button"
26
-
class="btn px-2 w-full flex items-center gap-2 no-underline text-sm">
27
-
{{ i "x" "w-3 h-3" }} cancel
3
+
<form
4
+
hx-post="/profile/pins"
5
+
hx-disabled-elt="#save-btn,#cancel-btn"
6
+
hx-swap="none"
7
+
hx-indicator="#spinner">
8
+
<div class="flex items-center justify-between mb-2">
9
+
<p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p>
10
+
<div class="flex items-center gap-2">
11
+
<button id="save-btn" type="submit" class="btn px-2 flex items-center gap-2 no-underline text-sm">
12
+
{{ i "check" "w-3 h-3" }} save
13
+
<span id="spinner" class="group">
14
+
{{ i "loader-circle" "w-3 h-3 animate-spin hidden group-[.htmx-request]:inline" }}
15
+
</span>
28
16
</button>
29
-
</a>
17
+
<a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline">
18
+
<button id="cancel-btn" type="button" class="btn px-2 w-full flex items-center gap-2 no-underline text-sm">
19
+
{{ i "x" "w-3 h-3" }} cancel
20
+
</button>
21
+
</a>
22
+
</div>
30
23
</div>
31
-
</div>
32
-
<div
33
-
id="repos"
34
-
class="grid grid-cols-1 gap-1 mb-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
35
-
{{ range $idx, $r := .AllRepos }}
36
-
<div
37
-
class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700">
38
-
<input
39
-
type="checkbox"
40
-
id="repo-{{ $idx }}"
41
-
name="pinnedRepo{{ $idx }}"
42
-
value="{{ .RepoAt }}"
43
-
{{ if .IsPinned }}checked{{ end }} />
44
-
<label
45
-
for="repo-{{ $idx }}"
46
-
class="my-0 py-0 normal-case font-normal w-full">
24
+
<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">
25
+
{{ range $idx, $r := .AllRepos }}
26
+
<div class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700">
27
+
<input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}>
28
+
<label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full">
47
29
<div class="flex justify-between items-center w-full">
48
-
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">
49
-
{{ resolve .Did }}/{{ .Name }}
50
-
</span>
30
+
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span>
51
31
<div class="flex gap-1 items-center">
52
32
{{ i "star" "size-4 fill-current" }}
53
33
<span>{{ .RepoStats.StarCount }}</span>
···
55
35
</div>
56
36
</label>
57
37
</div>
58
-
{{ end }}
59
-
</div>
60
-
</form>
38
+
{{ end }}
39
+
</div>
40
+
41
+
</form>
61
42
{{ end }}
+66
-112
appview/pages/templates/user/profile.html
+66
-112
appview/pages/templates/user/profile.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
2
3
3
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
-
<meta property="og:type" content="profile" />
6
-
<meta
7
-
property="og:url"
8
-
content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
9
-
<meta
10
-
property="og:description"
11
-
content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
+
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
12
8
{{ end }}
13
9
14
10
{{ define "content" }}
15
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
11
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
16
12
<div class="md:col-span-3 order-1 md:order-1">
17
13
<div class="grid grid-cols-1 gap-4">
18
14
{{ template "user/fragments/profileCard" .Card }}
19
-
{{ block "punchcard" .Punchcard }}{{ end }}
15
+
{{ block "punchcard" .Punchcard }} {{ end }}
20
16
</div>
21
17
</div>
22
18
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
···
26
22
</div>
27
23
</div>
28
24
<div class="md:col-span-4 order-3 md:order-3">
29
-
{{ block "profileTimeline" . }}{{ end }}
25
+
{{ block "profileTimeline" . }}{{ end }}
30
26
</div>
31
-
</div>
27
+
</div>
32
28
{{ end }}
33
29
34
30
{{ define "profileTimeline" }}
···
37
33
{{ with .ProfileTimeline }}
38
34
{{ range $idx, $byMonth := .ByMonth }}
39
35
{{ with $byMonth }}
40
-
<div
41
-
class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
42
-
{{ if eq $idx 0 }}
36
+
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
37
+
{{ if eq $idx 0 }}
43
38
44
-
{{ else }}
45
-
{{ $s := "s" }}
46
-
{{ if eq $idx 1 }}
47
-
{{ $s = "" }}
48
-
{{ end }}
49
-
<p class="text-sm font-bold dark:text-white mb-2">
50
-
{{ $idx }} month{{ $s }} ago
51
-
</p>
39
+
{{ else }}
40
+
{{ $s := "s" }}
41
+
{{ if eq $idx 1 }}
42
+
{{ $s = "" }}
52
43
{{ end }}
44
+
<p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p>
45
+
{{ end }}
46
+
47
+
{{ if .IsEmpty }}
48
+
<div class="text-gray-500 dark:text-gray-400">
49
+
No activity for this month
50
+
</div>
51
+
{{ else }}
52
+
<div class="flex flex-col gap-1">
53
+
{{ block "repoEvents" .RepoEvents }} {{ end }}
54
+
{{ block "issueEvents" .IssueEvents }} {{ end }}
55
+
{{ block "pullEvents" .PullEvents }} {{ end }}
56
+
</div>
57
+
{{ end }}
58
+
</div>
53
59
54
-
{{ if .IsEmpty }}
55
-
<div class="text-gray-500 dark:text-gray-400">
56
-
No activity for this month
57
-
</div>
58
-
{{ else }}
59
-
<div class="flex flex-col gap-1">
60
-
{{ block "repoEvents" .RepoEvents }}{{ end }}
61
-
{{ block "issueEvents" .IssueEvents }}{{ end }}
62
-
{{ block "pullEvents" .PullEvents }}{{ end }}
63
-
</div>
64
-
{{ end }}
65
-
</div>
66
60
{{ end }}
67
61
{{ else }}
68
62
<p class="dark:text-white">This user does not have any activity yet.</p>
···
74
68
{{ define "repoEvents" }}
75
69
{{ if gt (len .) 0 }}
76
70
<details>
77
-
<summary
78
-
class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
71
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
79
72
<div class="flex flex-wrap items-center gap-2">
80
73
{{ i "book-plus" "w-4 h-4" }}
81
-
created
82
-
{{ len . }}
83
-
{{ if eq (len .) 1 }}repository{{ else }}repositories{{ end }}
74
+
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
84
75
</div>
85
76
</summary>
86
77
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
···
93
84
{{ i "book-plus" "w-4 h-4" }}
94
85
{{ end }}
95
86
</span>
96
-
<a
97
-
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}"
98
-
class="no-underline hover:underline">
87
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
99
88
{{- .Repo.Name -}}
100
89
</a>
101
90
</div>
···
111
100
112
101
{{ if gt (len $items) 0 }}
113
102
<details>
114
-
<summary
115
-
class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
103
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
116
104
<div class="flex flex-wrap items-center gap-2">
117
105
{{ i "circle-dot" "w-4 h-4" }}
118
106
119
-
120
107
<div>
121
-
created
122
-
{{ len $items }}
123
-
{{ if eq (len $items) 1 }}issue{{ else }}issues{{ end }}
108
+
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
124
109
</div>
125
110
126
111
{{ if gt $stats.Open 0 }}
127
-
<span
128
-
class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
129
-
{{ $stats.Open }} open
112
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
113
+
{{$stats.Open}} open
130
114
</span>
131
115
{{ end }}
132
116
133
117
{{ if gt $stats.Closed 0 }}
134
-
<span
135
-
class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
136
-
{{ $stats.Closed }} closed
137
-
</span>
118
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
119
+
{{$stats.Closed}} closed
120
+
</span>
138
121
{{ end }}
139
122
140
123
</div>
···
145
128
{{ $repoName := .Metadata.Repo.Name }}
146
129
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
147
130
148
-
149
131
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
150
132
{{ if .Open }}
151
133
<span class="text-green-600 dark:text-green-500">
···
157
139
</span>
158
140
{{ end }}
159
141
<div class="flex-none min-w-8 text-right">
160
-
<span class="text-gray-500 dark:text-gray-400">
161
-
#{{ .IssueId }}
162
-
</span>
142
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
163
143
</div>
164
144
<div class="break-words max-w-full">
165
-
<a
166
-
href="/{{ $repoUrl }}/issues/{{ .IssueId }}"
167
-
class="no-underline hover:underline">
145
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
168
146
{{ .Title -}}
169
147
</a>
170
148
on
171
-
<a
172
-
href="/{{ $repoUrl }}"
173
-
class="no-underline hover:underline whitespace-nowrap">
174
-
{{ $repoUrl }}
149
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
150
+
{{$repoUrl}}
175
151
</a>
176
152
</div>
177
153
</div>
···
186
162
{{ $stats := .Stats }}
187
163
{{ if gt (len $items) 0 }}
188
164
<details>
189
-
<summary
190
-
class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
165
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
191
166
<div class="flex flex-wrap items-center gap-2">
192
167
{{ i "git-pull-request" "w-4 h-4" }}
193
168
194
-
195
169
<div>
196
-
created
197
-
{{ len $items }}
198
-
{{ if eq (len $items) 1 }}
199
-
pull request
200
-
{{ else }}
201
-
pull requests
202
-
{{ end }}
170
+
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
203
171
</div>
204
172
205
173
{{ if gt $stats.Open 0 }}
206
-
<span
207
-
class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
208
-
{{ $stats.Open }} open
174
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
175
+
{{$stats.Open}} open
209
176
</span>
210
177
{{ end }}
211
178
212
179
{{ if gt $stats.Merged 0 }}
213
-
<span
214
-
class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
215
-
{{ $stats.Merged }} merged
180
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
181
+
{{$stats.Merged}} merged
216
182
</span>
217
183
{{ end }}
218
184
185
+
219
186
{{ if gt $stats.Closed 0 }}
220
-
<span
221
-
class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
222
-
{{ $stats.Closed }} closed
223
-
</span>
187
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
188
+
{{$stats.Closed}} closed
189
+
</span>
224
190
{{ end }}
225
191
226
192
</div>
···
231
197
{{ $repoName := .Repo.Name }}
232
198
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
233
199
234
-
235
200
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
236
201
{{ if .State.IsOpen }}
237
202
<span class="text-green-600 dark:text-green-500">
···
247
212
</span>
248
213
{{ end }}
249
214
<div class="flex-none min-w-8 text-right">
250
-
<span class="text-gray-500 dark:text-gray-400">
251
-
#{{ .PullId }}
252
-
</span>
215
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
253
216
</div>
254
217
<div class="break-words max-w-full">
255
-
<a
256
-
href="/{{ $repoUrl }}/pulls/{{ .PullId }}"
257
-
class="no-underline hover:underline">
218
+
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
258
219
{{ .Title -}}
259
220
</a>
260
221
on
261
-
<a
262
-
href="/{{ $repoUrl }}"
263
-
class="no-underline hover:underline whitespace-nowrap">
264
-
{{ $repoUrl }}
222
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
223
+
{{$repoUrl}}
265
224
</a>
266
225
</div>
267
226
</div>
···
273
232
274
233
{{ define "ownRepos" }}
275
234
<div>
276
-
<div
277
-
class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2">
278
-
<a
279
-
href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
235
+
<div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2">
236
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
280
237
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
281
238
<span>PINNED REPOS</span>
282
-
<span
283
-
class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 ">
284
-
view all
285
-
{{ i "chevron-right" "w-4 h-4" }}
239
+
<span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 ">
240
+
view all {{ i "chevron-right" "w-4 h-4" }}
286
241
</span>
287
242
</a>
288
243
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
289
-
<button
244
+
<button
290
245
hx-get="profile/edit-pins"
291
246
hx-target="#all-repos"
292
247
class="btn py-0 font-normal text-sm flex gap-2 items-center group">
···
300
255
{{ range .Repos }}
301
256
{{ template "user/fragments/repoCard" (list $ . false) }}
302
257
{{ else }}
303
-
<p class="px-6 dark:text-white">
304
-
This user does not have any repos yet.
305
-
</p>
258
+
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
306
259
{{ end }}
307
260
</div>
308
261
</div>
···
316
269
{{ range .CollaboratingRepos }}
317
270
{{ template "user/fragments/repoCard" (list $ . true) }}
318
271
{{ else }}
319
-
<p class="px-6 dark:text-white">This user is not collaborating.</p>
272
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
320
273
{{ end }}
321
274
</div>
322
275
</div>
···
355
308
<div class="w-full h-full flex justify-center items-center">
356
309
<div
357
310
class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full"
358
-
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"></div>
311
+
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits">
312
+
</div>
359
313
</div>
360
314
{{ end }}
361
315
</div>
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
···
1
-
{{ define "repo/fragments/cloneInstructions" }}
2
-
{{ $knot := .RepoInfo.Knot }}
3
-
{{ if eq $knot "knot1.tangled.sh" }}
4
-
{{ $knot = "tangled.sh" }}
5
-
{{ end }}
6
-
<section
7
-
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"
8
-
>
9
-
<div class="flex flex-col gap-2">
10
-
<strong>push</strong>
11
-
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
12
-
<code class="dark:text-gray-100"
13
-
>git remote add origin
14
-
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
15
-
>
16
-
</div>
17
-
</div>
18
-
19
-
<div class="flex flex-col gap-2">
20
-
<strong>clone</strong>
21
-
<div class="md:pl-4 flex flex-col gap-2">
22
-
<div class="flex items-center gap-3">
23
-
<span
24
-
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
25
-
>HTTP</span
26
-
>
27
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
28
-
<code class="dark:text-gray-100"
29
-
>git clone
30
-
https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code
31
-
>
32
-
</div>
33
-
</div>
34
-
35
-
<div class="flex items-center gap-3">
36
-
<span
37
-
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
38
-
>SSH</span
39
-
>
40
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
41
-
<code class="dark:text-gray-100"
42
-
>git clone
43
-
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
44
-
>
45
-
</div>
46
-
</div>
47
-
</div>
48
-
</div>
49
-
50
-
<p class="py-2 text-gray-500 dark:text-gray-400">
51
-
Note that for self-hosted knots, clone URLs may be different based
52
-
on your setup.
53
-
</p>
54
-
</section>
55
-
{{ end }}
+1
-1
spindle/engine/ansi_stripper.go
spindle/engines/nixery/ansi_stripper.go
+1
-1
spindle/engine/ansi_stripper.go
spindle/engines/nixery/ansi_stripper.go
+1
-1
spindle/engine/envs.go
spindle/engines/nixery/envs.go
+1
-1
spindle/engine/envs.go
spindle/engines/nixery/envs.go
+1
-1
spindle/engine/envs_test.go
spindle/engines/nixery/envs_test.go
+1
-1
spindle/engine/envs_test.go
spindle/engines/nixery/envs_test.go
+7
spindle/engines/nixery/errors.go
+7
spindle/engines/nixery/errors.go
+8
-10
spindle/engine/logger.go
spindle/models/logger.go
+8
-10
spindle/engine/logger.go
spindle/models/logger.go
···
1
-
package engine
1
+
package models
2
2
3
3
import (
4
4
"encoding/json"
···
7
7
"os"
8
8
"path/filepath"
9
9
"strings"
10
-
11
-
"tangled.sh/tangled.sh/core/spindle/models"
12
10
)
13
11
14
12
type WorkflowLogger struct {
···
16
14
encoder *json.Encoder
17
15
}
18
16
19
-
func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) {
17
+
func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) {
20
18
path := LogFilePath(baseDir, wid)
21
19
22
20
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
30
28
}, nil
31
29
}
32
30
33
-
func LogFilePath(baseDir string, workflowID models.WorkflowId) string {
31
+
func LogFilePath(baseDir string, workflowID WorkflowId) string {
34
32
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
35
33
return logFilePath
36
34
}
···
47
45
}
48
46
}
49
47
50
-
func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer {
48
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
51
49
return &controlWriter{
52
50
logger: l,
53
51
idx: idx,
···
62
60
63
61
func (w *dataWriter) Write(p []byte) (int, error) {
64
62
line := strings.TrimRight(string(p), "\r\n")
65
-
entry := models.NewDataLogLine(line, w.stream)
63
+
entry := NewDataLogLine(line, w.stream)
66
64
if err := w.logger.encoder.Encode(entry); err != nil {
67
65
return 0, err
68
66
}
···
72
70
type controlWriter struct {
73
71
logger *WorkflowLogger
74
72
idx int
75
-
step models.Step
73
+
step Step
76
74
}
77
75
78
76
func (w *controlWriter) Write(_ []byte) (int, error) {
79
-
entry := models.NewControlLogLine(w.idx, w.step)
77
+
entry := NewControlLogLine(w.idx, w.step)
80
78
if err := w.logger.encoder.Encode(entry); err != nil {
81
79
return 0, err
82
80
}
83
-
return len(w.step.Name), nil
81
+
return len(w.step.Name()), nil
84
82
}
+8
-103
spindle/models/pipeline.go
+8
-103
spindle/models/pipeline.go
···
1
1
package models
2
2
3
-
import (
4
-
"path"
5
-
6
-
"tangled.sh/tangled.sh/core/api/tangled"
7
-
"tangled.sh/tangled.sh/core/spindle/config"
8
-
)
9
-
10
3
type Pipeline struct {
11
4
RepoOwner string
12
5
RepoName string
13
-
Workflows []Workflow
6
+
Workflows map[Engine][]Workflow
14
7
}
15
8
16
-
type Step struct {
17
-
Command string
18
-
Name string
19
-
Environment map[string]string
20
-
Kind StepKind
9
+
type Step interface {
10
+
Name() string
11
+
Command() string
12
+
Kind() StepKind
21
13
}
22
14
23
15
type StepKind int
···
30
22
)
31
23
32
24
type Workflow struct {
33
-
Steps []Step
34
-
Environment map[string]string
35
-
Name string
36
-
Image string
37
-
}
38
-
39
-
// setupSteps get added to start of Steps
40
-
type setupSteps []Step
41
-
42
-
// addStep adds a step to the beginning of the workflow's steps.
43
-
func (ss *setupSteps) addStep(step Step) {
44
-
*ss = append(*ss, step)
45
-
}
46
-
47
-
// ToPipeline converts a tangled.Pipeline into a model.Pipeline.
48
-
// In the process, dependencies are resolved: nixpkgs deps
49
-
// are constructed atop nixery and set as the Workflow.Image,
50
-
// and ones from custom registries
51
-
func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline {
52
-
workflows := []Workflow{}
53
-
54
-
for _, twf := range pl.Workflows {
55
-
swf := &Workflow{}
56
-
for _, tstep := range twf.Steps {
57
-
sstep := Step{}
58
-
sstep.Environment = stepEnvToMap(tstep.Environment)
59
-
sstep.Command = tstep.Command
60
-
sstep.Name = tstep.Name
61
-
sstep.Kind = StepKindUser
62
-
swf.Steps = append(swf.Steps, sstep)
63
-
}
64
-
swf.Name = twf.Name
65
-
swf.Environment = workflowEnvToMap(twf.Environment)
66
-
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
67
-
68
-
setup := &setupSteps{}
69
-
70
-
setup.addStep(nixConfStep())
71
-
setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev))
72
-
// this step could be empty
73
-
if s := dependencyStep(*twf); s != nil {
74
-
setup.addStep(*s)
75
-
}
76
-
77
-
// append setup steps in order to the start of workflow steps
78
-
swf.Steps = append(*setup, swf.Steps...)
79
-
80
-
workflows = append(workflows, *swf)
81
-
}
82
-
repoOwner := pl.TriggerMetadata.Repo.Did
83
-
repoName := pl.TriggerMetadata.Repo.Repo
84
-
return &Pipeline{
85
-
RepoOwner: repoOwner,
86
-
RepoName: repoName,
87
-
Workflows: workflows,
88
-
}
89
-
}
90
-
91
-
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
92
-
envMap := map[string]string{}
93
-
for _, env := range envs {
94
-
if env != nil {
95
-
envMap[env.Key] = env.Value
96
-
}
97
-
}
98
-
return envMap
99
-
}
100
-
101
-
func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
102
-
envMap := map[string]string{}
103
-
for _, env := range envs {
104
-
if env != nil {
105
-
envMap[env.Key] = env.Value
106
-
}
107
-
}
108
-
return envMap
109
-
}
110
-
111
-
func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string {
112
-
var dependencies string
113
-
for _, d := range deps {
114
-
if d.Registry == "nixpkgs" {
115
-
dependencies = path.Join(d.Packages...)
116
-
}
117
-
}
118
-
119
-
// load defaults from somewhere else
120
-
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
121
-
122
-
return path.Join(nixery, dependencies)
25
+
Steps []Step
26
+
Name string
27
+
Data any
123
28
}
+1
-86
workflow/def_test.go
+1
-86
workflow/def_test.go
···
10
10
yamlData := `
11
11
when:
12
12
- event: ["push", "pull_request"]
13
-
branch: ["main", "develop"]
14
-
15
-
dependencies:
16
-
nixpkgs:
17
-
- go
18
-
- git
19
-
- curl
20
-
21
-
steps:
22
-
- name: "Test"
23
-
command: |
24
-
go test ./...`
13
+
branch: ["main", "develop"]`
25
14
26
15
wf, err := FromFile("test.yml", []byte(yamlData))
27
16
assert.NoError(t, err, "YAML should unmarshal without error")
···
30
19
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
31
20
assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event)
32
21
33
-
assert.Len(t, wf.Steps, 1)
34
-
assert.Equal(t, "Test", wf.Steps[0].Name)
35
-
assert.Equal(t, "go test ./...", wf.Steps[0].Command)
36
-
37
-
pkgs, ok := wf.Dependencies["nixpkgs"]
38
-
assert.True(t, ok, "`nixpkgs` should be present in dependencies")
39
-
assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs)
40
-
41
22
assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
42
23
}
43
24
44
-
func TestUnmarshalCustomRegistry(t *testing.T) {
45
-
yamlData := `
46
-
when:
47
-
- event: push
48
-
branch: main
49
-
50
-
dependencies:
51
-
git+https://tangled.sh/@oppi.li/tbsp:
52
-
- tbsp
53
-
git+https://git.peppe.rs/languages/statix:
54
-
- statix
55
-
56
-
steps:
57
-
- name: "Check"
58
-
command: |
59
-
statix check`
60
-
61
-
wf, err := FromFile("test.yml", []byte(yamlData))
62
-
assert.NoError(t, err, "YAML should unmarshal without error")
63
-
64
-
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
65
-
assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch)
66
-
67
-
assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"])
68
-
assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"])
69
-
}
70
-
71
25
func TestUnmarshalCloneFalse(t *testing.T) {
72
26
yamlData := `
73
27
when:
···
75
29
76
30
clone:
77
31
skip: true
78
-
79
-
dependencies:
80
-
nixpkgs:
81
-
- python3
82
-
83
-
steps:
84
-
- name: Notify
85
-
command: |
86
-
python3 ./notify.py
87
32
`
88
33
89
34
wf, err := FromFile("test.yml", []byte(yamlData))
···
93
38
94
39
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
95
40
}
96
-
97
-
func TestUnmarshalEnv(t *testing.T) {
98
-
yamlData := `
99
-
when:
100
-
- event: ["pull_request_close"]
101
-
102
-
clone:
103
-
skip: false
104
-
105
-
environment:
106
-
HOME: /home/foo bar/baz
107
-
CGO_ENABLED: 1
108
-
109
-
steps:
110
-
- name: Something
111
-
command: echo "hello"
112
-
environment:
113
-
FOO: bar
114
-
BAZ: qux
115
-
`
116
-
117
-
wf, err := FromFile("test.yml", []byte(yamlData))
118
-
assert.NoError(t, err)
119
-
120
-
assert.Len(t, wf.Environment, 2)
121
-
assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"])
122
-
assert.Equal(t, "1", wf.Environment["CGO_ENABLED"])
123
-
assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"])
124
-
assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"])
125
-
}
+1
-1
appview/pages/templates/repo/fragments/repoDescription.html
+1
-1
appview/pages/templates/repo/fragments/repoDescription.html
···
1
1
{{ define "repo/fragments/repoDescription" }}
2
2
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
3
3
{{ if .RepoInfo.Description }}
4
-
{{ .RepoInfo.Description }}
4
+
{{ .RepoInfo.Description | description }}
5
5
{{ else }}
6
6
<span class="italic">this repo has no description</span>
7
7
{{ end }}
+1
-1
appview/pages/templates/strings/fragments/form.html
+1
-1
appview/pages/templates/strings/fragments/form.html
···
13
13
type="text"
14
14
id="filename"
15
15
name="filename"
16
-
placeholder="Filename with extension"
16
+
placeholder="Filename"
17
17
required
18
18
value="{{ .String.Filename }}"
19
19
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
+62
appview/pages/templates/user/settings/fragments/emailListing.html
···
1
+
{{ define "user/settings/fragments/emailListing" }}
2
+
{{ $root := index . 0 }}
3
+
{{ $email := index . 1 }}
4
+
<div id="email-{{$email.Address}}" class="flex items-center justify-between p-2">
5
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
6
+
<div class="flex items-center gap-2">
7
+
{{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }}
8
+
<span class="font-bold">
9
+
{{ $email.Address }}
10
+
</span>
11
+
<div class="inline-flex items-center gap-1">
12
+
{{ if $email.Verified }}
13
+
<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>
14
+
{{ else }}
15
+
<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>
16
+
{{ end }}
17
+
{{ if $email.Primary }}
18
+
<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>
19
+
{{ end }}
20
+
</div>
21
+
</div>
22
+
<div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
23
+
<span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span>
24
+
</div>
25
+
</div>
26
+
<div class="flex gap-2 items-center">
27
+
{{ if not $email.Verified }}
28
+
<button
29
+
class="btn flex gap-2 text-sm px-2 py-1"
30
+
hx-post="/settings/emails/verify/resend"
31
+
hx-swap="none"
32
+
hx-vals='{"email": "{{ $email.Address }}"}'>
33
+
{{ i "rotate-cw" "w-4 h-4" }}
34
+
<span class="hidden md:inline">resend</span>
35
+
</button>
36
+
{{ end }}
37
+
{{ if and (not $email.Primary) $email.Verified }}
38
+
<button
39
+
class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
40
+
hx-post="/settings/emails/primary"
41
+
hx-swap="none"
42
+
hx-vals='{"email": "{{ $email.Address }}"}'>
43
+
set as primary
44
+
</button>
45
+
{{ end }}
46
+
{{ if not $email.Primary }}
47
+
<button
48
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
49
+
title="Delete email"
50
+
hx-delete="/settings/emails"
51
+
hx-swap="none"
52
+
hx-vals='{"email": "{{ $email.Address }}"}'
53
+
hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?"
54
+
>
55
+
{{ i "trash-2" "w-5 h-5" }}
56
+
<span class="hidden md:inline">delete</span>
57
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
58
+
</button>
59
+
{{ end }}
60
+
</div>
61
+
</div>
62
+
{{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
+31
appview/pages/templates/user/settings/fragments/keyListing.html
···
1
+
{{ define "user/settings/fragments/keyListing" }}
2
+
{{ $root := index . 0 }}
3
+
{{ $key := index . 1 }}
4
+
<div id="key-{{$key.Name}}" class="flex items-center justify-between p-2">
5
+
<div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]">
6
+
<div class="flex items-center gap-2">
7
+
<span>{{ i "key" "w-4" "h-4" }}</span>
8
+
<span class="font-bold">
9
+
{{ $key.Name }}
10
+
</span>
11
+
</div>
12
+
<span class="font-mono text-sm text-gray-500 dark:text-gray-400">
13
+
{{ sshFingerprint $key.Key }}
14
+
</span>
15
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
16
+
<span>added {{ template "repo/fragments/time" $key.Created }}</span>
17
+
</div>
18
+
</div>
19
+
<button
20
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
21
+
title="Delete key"
22
+
hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}"
23
+
hx-swap="none"
24
+
hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?"
25
+
>
26
+
{{ i "trash-2" "w-5 h-5" }}
27
+
<span class="hidden md:inline">delete</span>
28
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
29
+
</button>
30
+
</div>
31
+
{{ end }}
+34
api/tangled/repocreate.go
+34
api/tangled/repocreate.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.create
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoCreateNSID = "sh.tangled.repo.create"
15
+
)
16
+
17
+
// RepoCreate_Input is the input argument to a sh.tangled.repo.create call.
18
+
type RepoCreate_Input struct {
19
+
// defaultBranch: Default branch to push to
20
+
DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"`
21
+
// rkey: Rkey of the repository record
22
+
Rkey string `json:"rkey" cborgen:"rkey"`
23
+
// source: A source URL to clone from, populate this when forking or importing a repository.
24
+
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
25
+
}
26
+
27
+
// RepoCreate calls the XRPC method "sh.tangled.repo.create".
28
+
func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error {
29
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil {
30
+
return err
31
+
}
32
+
33
+
return nil
34
+
}
+45
api/tangled/repoforkStatus.go
+45
api/tangled/repoforkStatus.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.forkStatus
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoForkStatusNSID = "sh.tangled.repo.forkStatus"
15
+
)
16
+
17
+
// RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call.
18
+
type RepoForkStatus_Input struct {
19
+
// branch: Branch to check status for
20
+
Branch string `json:"branch" cborgen:"branch"`
21
+
// did: DID of the fork owner
22
+
Did string `json:"did" cborgen:"did"`
23
+
// hiddenRef: Hidden ref to use for comparison
24
+
HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"`
25
+
// name: Name of the forked repository
26
+
Name string `json:"name" cborgen:"name"`
27
+
// source: Source repository URL
28
+
Source string `json:"source" cborgen:"source"`
29
+
}
30
+
31
+
// RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call.
32
+
type RepoForkStatus_Output struct {
33
+
// status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch
34
+
Status int64 `json:"status" cborgen:"status"`
35
+
}
36
+
37
+
// RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus".
38
+
func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) {
39
+
var out RepoForkStatus_Output
40
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil {
41
+
return nil, err
42
+
}
43
+
44
+
return &out, nil
45
+
}
+36
api/tangled/repoforkSync.go
+36
api/tangled/repoforkSync.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.forkSync
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoForkSyncNSID = "sh.tangled.repo.forkSync"
15
+
)
16
+
17
+
// RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call.
18
+
type RepoForkSync_Input struct {
19
+
// branch: Branch to sync
20
+
Branch string `json:"branch" cborgen:"branch"`
21
+
// did: DID of the fork owner
22
+
Did string `json:"did" cborgen:"did"`
23
+
// name: Name of the forked repository
24
+
Name string `json:"name" cborgen:"name"`
25
+
// source: AT-URI of the source repository
26
+
Source string `json:"source" cborgen:"source"`
27
+
}
28
+
29
+
// RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync".
30
+
func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error {
31
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil {
32
+
return err
33
+
}
34
+
35
+
return nil
36
+
}
+44
api/tangled/repomerge.go
+44
api/tangled/repomerge.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.merge
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoMergeNSID = "sh.tangled.repo.merge"
15
+
)
16
+
17
+
// RepoMerge_Input is the input argument to a sh.tangled.repo.merge call.
18
+
type RepoMerge_Input struct {
19
+
// authorEmail: Author email for the merge commit
20
+
AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"`
21
+
// authorName: Author name for the merge commit
22
+
AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"`
23
+
// branch: Target branch to merge into
24
+
Branch string `json:"branch" cborgen:"branch"`
25
+
// commitBody: Additional commit message body
26
+
CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"`
27
+
// commitMessage: Merge commit message
28
+
CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"`
29
+
// did: DID of the repository owner
30
+
Did string `json:"did" cborgen:"did"`
31
+
// name: Name of the repository
32
+
Name string `json:"name" cborgen:"name"`
33
+
// patch: Patch content to merge
34
+
Patch string `json:"patch" cborgen:"patch"`
35
+
}
36
+
37
+
// RepoMerge calls the XRPC method "sh.tangled.repo.merge".
38
+
func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error {
39
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil {
40
+
return err
41
+
}
42
+
43
+
return nil
44
+
}
+57
api/tangled/repomergeCheck.go
+57
api/tangled/repomergeCheck.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.mergeCheck
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck"
15
+
)
16
+
17
+
// RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema.
18
+
type RepoMergeCheck_ConflictInfo struct {
19
+
// filename: Name of the conflicted file
20
+
Filename string `json:"filename" cborgen:"filename"`
21
+
// reason: Reason for the conflict
22
+
Reason string `json:"reason" cborgen:"reason"`
23
+
}
24
+
25
+
// RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call.
26
+
type RepoMergeCheck_Input struct {
27
+
// branch: Target branch to merge into
28
+
Branch string `json:"branch" cborgen:"branch"`
29
+
// did: DID of the repository owner
30
+
Did string `json:"did" cborgen:"did"`
31
+
// name: Name of the repository
32
+
Name string `json:"name" cborgen:"name"`
33
+
// patch: Patch or pull request to check for merge conflicts
34
+
Patch string `json:"patch" cborgen:"patch"`
35
+
}
36
+
37
+
// RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call.
38
+
type RepoMergeCheck_Output struct {
39
+
// conflicts: List of files with merge conflicts
40
+
Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"`
41
+
// error: Error message if check failed
42
+
Error *string `json:"error,omitempty" cborgen:"error,omitempty"`
43
+
// is_conflicted: Whether the merge has conflicts
44
+
Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"`
45
+
// message: Additional message about the merge check
46
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
47
+
}
48
+
49
+
// RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck".
50
+
func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) {
51
+
var out RepoMergeCheck_Output
52
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil {
53
+
return nil, err
54
+
}
55
+
56
+
return &out, nil
57
+
}
+24
lexicons/knot/knot.json
+24
lexicons/knot/knot.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.knot",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"createdAt"
14
+
],
15
+
"properties": {
16
+
"createdAt": {
17
+
"type": "string",
18
+
"format": "datetime"
19
+
}
20
+
}
21
+
}
22
+
}
23
+
}
24
+
}
+33
lexicons/repo/create.json
+33
lexicons/repo/create.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.create",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Create a new repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"rkey"
14
+
],
15
+
"properties": {
16
+
"rkey": {
17
+
"type": "string",
18
+
"description": "Rkey of the repository record"
19
+
},
20
+
"defaultBranch": {
21
+
"type": "string",
22
+
"description": "Default branch to push to"
23
+
},
24
+
"source": {
25
+
"type": "string",
26
+
"description": "A source URL to clone from, populate this when forking or importing a repository."
27
+
}
28
+
}
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
+53
lexicons/repo/forkStatus.json
+53
lexicons/repo/forkStatus.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.forkStatus",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Check fork status relative to upstream source",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": ["did", "name", "source", "branch", "hiddenRef"],
13
+
"properties": {
14
+
"did": {
15
+
"type": "string",
16
+
"format": "did",
17
+
"description": "DID of the fork owner"
18
+
},
19
+
"name": {
20
+
"type": "string",
21
+
"description": "Name of the forked repository"
22
+
},
23
+
"source": {
24
+
"type": "string",
25
+
"description": "Source repository URL"
26
+
},
27
+
"branch": {
28
+
"type": "string",
29
+
"description": "Branch to check status for"
30
+
},
31
+
"hiddenRef": {
32
+
"type": "string",
33
+
"description": "Hidden ref to use for comparison"
34
+
}
35
+
}
36
+
}
37
+
},
38
+
"output": {
39
+
"encoding": "application/json",
40
+
"schema": {
41
+
"type": "object",
42
+
"required": ["status"],
43
+
"properties": {
44
+
"status": {
45
+
"type": "integer",
46
+
"description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch"
47
+
}
48
+
}
49
+
}
50
+
}
51
+
}
52
+
}
53
+
}
+42
lexicons/repo/forkSync.json
+42
lexicons/repo/forkSync.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.forkSync",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Sync a forked repository with its upstream source",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"did",
14
+
"source",
15
+
"name",
16
+
"branch"
17
+
],
18
+
"properties": {
19
+
"did": {
20
+
"type": "string",
21
+
"format": "did",
22
+
"description": "DID of the fork owner"
23
+
},
24
+
"source": {
25
+
"type": "string",
26
+
"format": "at-uri",
27
+
"description": "AT-URI of the source repository"
28
+
},
29
+
"name": {
30
+
"type": "string",
31
+
"description": "Name of the forked repository"
32
+
},
33
+
"branch": {
34
+
"type": "string",
35
+
"description": "Branch to sync"
36
+
}
37
+
}
38
+
}
39
+
}
40
+
}
41
+
}
42
+
}
+52
lexicons/repo/merge.json
+52
lexicons/repo/merge.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.merge",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Merge a patch into a repository branch",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": ["did", "name", "patch", "branch"],
13
+
"properties": {
14
+
"did": {
15
+
"type": "string",
16
+
"format": "did",
17
+
"description": "DID of the repository owner"
18
+
},
19
+
"name": {
20
+
"type": "string",
21
+
"description": "Name of the repository"
22
+
},
23
+
"patch": {
24
+
"type": "string",
25
+
"description": "Patch content to merge"
26
+
},
27
+
"branch": {
28
+
"type": "string",
29
+
"description": "Target branch to merge into"
30
+
},
31
+
"authorName": {
32
+
"type": "string",
33
+
"description": "Author name for the merge commit"
34
+
},
35
+
"authorEmail": {
36
+
"type": "string",
37
+
"description": "Author email for the merge commit"
38
+
},
39
+
"commitBody": {
40
+
"type": "string",
41
+
"description": "Additional commit message body"
42
+
},
43
+
"commitMessage": {
44
+
"type": "string",
45
+
"description": "Merge commit message"
46
+
}
47
+
}
48
+
}
49
+
}
50
+
}
51
+
}
52
+
}
+79
lexicons/repo/mergeCheck.json
+79
lexicons/repo/mergeCheck.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.mergeCheck",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Check if a merge is possible between two branches",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": ["did", "name", "patch", "branch"],
13
+
"properties": {
14
+
"did": {
15
+
"type": "string",
16
+
"format": "did",
17
+
"description": "DID of the repository owner"
18
+
},
19
+
"name": {
20
+
"type": "string",
21
+
"description": "Name of the repository"
22
+
},
23
+
"patch": {
24
+
"type": "string",
25
+
"description": "Patch or pull request to check for merge conflicts"
26
+
},
27
+
"branch": {
28
+
"type": "string",
29
+
"description": "Target branch to merge into"
30
+
}
31
+
}
32
+
}
33
+
},
34
+
"output": {
35
+
"encoding": "application/json",
36
+
"schema": {
37
+
"type": "object",
38
+
"required": ["is_conflicted"],
39
+
"properties": {
40
+
"is_conflicted": {
41
+
"type": "boolean",
42
+
"description": "Whether the merge has conflicts"
43
+
},
44
+
"conflicts": {
45
+
"type": "array",
46
+
"description": "List of files with merge conflicts",
47
+
"items": {
48
+
"type": "ref",
49
+
"ref": "#conflictInfo"
50
+
}
51
+
},
52
+
"message": {
53
+
"type": "string",
54
+
"description": "Additional message about the merge check"
55
+
},
56
+
"error": {
57
+
"type": "string",
58
+
"description": "Error message if check failed"
59
+
}
60
+
}
61
+
}
62
+
}
63
+
},
64
+
"conflictInfo": {
65
+
"type": "object",
66
+
"required": ["filename", "reason"],
67
+
"properties": {
68
+
"filename": {
69
+
"type": "string",
70
+
"description": "Name of the conflicted file"
71
+
},
72
+
"reason": {
73
+
"type": "string",
74
+
"description": "Reason for the conflict"
75
+
}
76
+
}
77
+
}
78
+
}
79
+
}
+22
api/tangled/tangledknot.go
+22
api/tangled/tangledknot.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.knot
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
KnotNSID = "sh.tangled.knot"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.knot", &Knot{})
17
+
} //
18
+
// RECORDTYPE: Knot
19
+
type Knot struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
}
+93
-28
appview/pages/templates/knots/dashboard.html
+93
-28
appview/pages/templates/knots/dashboard.html
···
1
-
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
1
+
{{ define "title" }}{{ .Registration.Domain }} · knots{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
5
-
<div class="flex justify-between items-center">
6
-
<div id="left-side" class="flex gap-2 items-center">
7
-
<h1 class="text-xl font-bold dark:text-white">
8
-
{{ .Registration.Domain }}
9
-
</h1>
10
-
<span class="text-gray-500 text-base">
11
-
{{ template "repo/fragments/shortTimeAgo" .Registration.Created }}
12
-
</span>
13
-
</div>
14
-
<div id="right-side" class="flex gap-2">
15
-
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
16
-
{{ if .Registration.Registered }}
17
-
<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>
4
+
<div class="px-6 py-4">
5
+
<div class="flex justify-between items-center">
6
+
<h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1>
7
+
<div id="right-side" class="flex gap-2">
8
+
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
9
+
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }}
10
+
{{ if .Registration.IsRegistered }}
11
+
<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>
12
+
{{ if $isOwner }}
18
13
{{ template "knots/fragments/addMemberModal" .Registration }}
19
-
{{ else }}
20
-
<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>
21
14
{{ end }}
22
-
</div>
15
+
{{ else if .Registration.IsReadOnly }}
16
+
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}">
17
+
{{ i "shield-alert" "w-4 h-4" }} read-only
18
+
</span>
19
+
{{ if $isOwner }}
20
+
{{ block "retryButton" .Registration }} {{ end }}
21
+
{{ end }}
22
+
{{ else }}
23
+
<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>
24
+
{{ if $isOwner }}
25
+
{{ block "retryButton" .Registration }} {{ end }}
26
+
{{ end }}
27
+
{{ end }}
28
+
29
+
{{ if $isOwner }}
30
+
{{ block "deleteButton" .Registration }} {{ end }}
31
+
{{ end }}
23
32
</div>
24
-
<div id="operation-error" class="dark:text-red-400"></div>
25
33
</div>
34
+
<div id="operation-error" class="dark:text-red-400"></div>
35
+
</div>
26
36
27
-
{{ if .Members }}
28
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
29
-
<div class="flex flex-col gap-2">
30
-
{{ block "knotMember" . }} {{ end }}
31
-
</div>
32
-
</section>
33
-
{{ end }}
37
+
{{ if .Members }}
38
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
39
+
<div class="flex flex-col gap-2">
40
+
{{ block "member" . }} {{ end }}
41
+
</div>
42
+
</section>
43
+
{{ end }}
34
44
{{ end }}
35
45
36
-
{{ define "knotMember" }}
46
+
47
+
{{ define "member" }}
37
48
{{ range .Members }}
38
49
<div>
39
50
<div class="flex justify-between items-center">
···
41
52
{{ template "user/fragments/picHandleLink" . }}
42
53
<span class="ml-2 font-mono text-gray-500">{{.}}</span>
43
54
</div>
55
+
{{ if ne $.LoggedInUser.Did . }}
56
+
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
57
+
{{ end }}
44
58
</div>
45
59
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
46
60
{{ $repos := index $.Repos . }}
···
53
67
</div>
54
68
{{ else }}
55
69
<div class="text-gray-500 dark:text-gray-400">
56
-
No repositories created yet.
70
+
No repositories configured yet.
57
71
</div>
58
72
{{ end }}
59
73
</div>
60
74
</div>
61
75
{{ end }}
62
76
{{ end }}
77
+
78
+
{{ define "deleteButton" }}
79
+
<button
80
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
81
+
title="Delete knot"
82
+
hx-delete="/knots/{{ .Domain }}"
83
+
hx-swap="outerHTML"
84
+
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
85
+
hx-headers='{"shouldRedirect": "true"}'
86
+
>
87
+
{{ i "trash-2" "w-5 h-5" }}
88
+
<span class="hidden md:inline">delete</span>
89
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
90
+
</button>
91
+
{{ end }}
92
+
93
+
94
+
{{ define "retryButton" }}
95
+
<button
96
+
class="btn gap-2 group"
97
+
title="Retry knot verification"
98
+
hx-post="/knots/{{ .Domain }}/retry"
99
+
hx-swap="none"
100
+
hx-headers='{"shouldRefresh": "true"}'
101
+
>
102
+
{{ i "rotate-ccw" "w-5 h-5" }}
103
+
<span class="hidden md:inline">retry</span>
104
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
105
+
</button>
106
+
{{ end }}
107
+
108
+
109
+
{{ define "removeMemberButton" }}
110
+
{{ $root := index . 0 }}
111
+
{{ $member := index . 1 }}
112
+
{{ $memberHandle := resolve $member }}
113
+
<button
114
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
115
+
title="Remove member"
116
+
hx-post="/knots/{{ $root.Registration.Domain }}/remove"
117
+
hx-swap="none"
118
+
hx-vals='{"member": "{{$member}}" }'
119
+
hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
120
+
>
121
+
{{ i "user-minus" "w-4 h-4" }}
122
+
remove
123
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
124
+
</button>
125
+
{{ end }}
126
+
127
+
+6
-7
appview/pages/templates/knots/fragments/addMemberModal.html
+6
-7
appview/pages/templates/knots/fragments/addMemberModal.html
···
1
1
{{ define "knots/fragments/addMemberModal" }}
2
2
<button
3
3
class="btn gap-2 group"
4
-
title="Add member to this spindle"
4
+
title="Add member to this knot"
5
5
popovertarget="add-member-{{ .Id }}"
6
6
popovertargetaction="toggle"
7
7
>
···
20
20
21
21
{{ define "addKnotMemberPopover" }}
22
22
<form
23
-
hx-put="/knots/{{ .Domain }}/member"
23
+
hx-post="/knots/{{ .Domain }}/add"
24
24
hx-indicator="#spinner"
25
25
hx-swap="none"
26
26
class="flex flex-col gap-2"
···
28
28
<label for="member-did-{{ .Id }}" class="uppercase p-0">
29
29
ADD MEMBER
30
30
</label>
31
-
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p>
31
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
32
32
<input
33
33
type="text"
34
34
id="member-did-{{ .Id }}"
35
-
name="subject"
35
+
name="member"
36
36
required
37
37
placeholder="@foo.bsky.social"
38
38
/>
39
39
<div class="flex gap-2 pt-2">
40
-
<button
40
+
<button
41
41
type="button"
42
42
popovertarget="add-member-{{ .Id }}"
43
43
popovertargetaction="hide"
···
54
54
</div>
55
55
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
56
</form>
57
-
{{ end }}
58
-
57
+
{{ end }}
+2
-2
appview/pages/templates/spindles/fragments/addMemberModal.html
+2
-2
appview/pages/templates/spindles/fragments/addMemberModal.html
···
14
14
id="add-member-{{ .Instance }}"
15
15
popover
16
16
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">
17
-
{{ block "addMemberPopover" . }} {{ end }}
17
+
{{ block "addSpindleMemberPopover" . }} {{ end }}
18
18
</div>
19
19
{{ end }}
20
20
21
-
{{ define "addMemberPopover" }}
21
+
{{ define "addSpindleMemberPopover" }}
22
22
<form
23
23
hx-post="/spindles/{{ .Instance }}/add"
24
24
hx-indicator="#spinner"
-7
nix/modules/knot.nix
-7
nix/modules/knot.nix
···
99
99
description = "DID of owner (required)";
100
100
};
101
101
102
-
secretFile = mkOption {
103
-
type = lib.types.path;
104
-
example = "KNOT_SERVER_SECRET=<hash>";
105
-
description = "File containing secret key provided by appview (required)";
106
-
};
107
-
108
102
dbPath = mkOption {
109
103
type = types.path;
110
104
default = "${cfg.stateDir}/knotserver.db";
···
207
201
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
208
202
"KNOT_SERVER_OWNER=${cfg.server.owner}"
209
203
];
210
-
EnvironmentFile = cfg.server.secretFile;
211
204
ExecStart = "${cfg.package}/bin/knot server";
212
205
Restart = "always";
213
206
};
+7
appview/pages/templates/layouts/topbar.html
+7
appview/pages/templates/layouts/topbar.html
+2
-2
rbac/rbac.go
+2
-2
rbac/rbac.go
···
281
281
return e.E.Enforce(user, domain, domain, "repo:create")
282
282
}
283
283
284
-
func (e *Enforcer) IsRepoDeleteAllowed(user, domain string) (bool, error) {
285
-
return e.E.Enforce(user, domain, domain, "repo:delete")
284
+
func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) {
285
+
return e.E.Enforce(user, domain, repo, "repo:delete")
286
286
}
287
287
288
288
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+4
-7
api/tangled/pullcomment.go
+4
-7
api/tangled/pullcomment.go
···
17
17
} //
18
18
// RECORDTYPE: RepoPullComment
19
19
type RepoPullComment struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
-
Body string `json:"body" cborgen:"body"`
22
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
-
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
25
-
Pull string `json:"pull" cborgen:"pull"`
26
-
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
+
Body string `json:"body" cborgen:"body"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Pull string `json:"pull" cborgen:"pull"`
27
24
}
-11
lexicons/pulls/comment.json
-11
lexicons/pulls/comment.json
+7
-2
api/tangled/repopull.go
+7
-2
api/tangled/repopull.go
···
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
23
Patch string `json:"patch" cborgen:"patch"`
24
24
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
25
-
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
26
-
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
25
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
27
26
Title string `json:"title" cborgen:"title"`
28
27
}
29
28
···
33
32
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
34
33
Sha string `json:"sha" cborgen:"sha"`
35
34
}
35
+
36
+
// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
37
+
type RepoPull_Target struct {
38
+
Branch string `json:"branch" cborgen:"branch"`
39
+
Repo string `json:"repo" cborgen:"repo"`
40
+
}
+20
-8
lexicons/pulls/pull.json
+20
-8
lexicons/pulls/pull.json
···
10
10
"record": {
11
11
"type": "object",
12
12
"required": [
13
-
"targetRepo",
14
-
"targetBranch",
13
+
"target",
15
14
"title",
16
15
"patch",
17
16
"createdAt"
18
17
],
19
18
"properties": {
20
-
"targetRepo": {
21
-
"type": "string",
22
-
"format": "at-uri"
23
-
},
24
-
"targetBranch": {
25
-
"type": "string"
19
+
"target": {
20
+
"type": "ref",
21
+
"ref": "#target"
26
22
},
27
23
"title": {
28
24
"type": "string"
···
44
40
}
45
41
}
46
42
},
43
+
"target": {
44
+
"type": "object",
45
+
"required": [
46
+
"repo",
47
+
"branch"
48
+
],
49
+
"properties": {
50
+
"repo": {
51
+
"type": "string",
52
+
"format": "at-uri"
53
+
},
54
+
"branch": {
55
+
"type": "string"
56
+
}
57
+
}
58
+
},
47
59
"source": {
48
60
"type": "object",
49
61
"required": [
+2
-2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
+2
-2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
19
19
>
20
20
<option disabled selected>select a fork</option>
21
21
{{ range .Forks }}
22
-
<option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
23
-
{{ .Name }}
22
+
<option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
23
+
{{ .Did | resolve }}/{{ .Name }}
24
24
</option>
25
25
{{ end }}
26
26
</select>
-2
api/tangled/repoissue.go
-2
api/tangled/repoissue.go
···
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
IssueId int64 `json:"issueId" cborgen:"issueId"`
24
-
Owner string `json:"owner" cborgen:"owner"`
25
23
Repo string `json:"repo" cborgen:"repo"`
26
24
Title string `json:"title" cborgen:"title"`
27
25
}
+1
-14
lexicons/issue/issue.json
+1
-14
lexicons/issue/issue.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"issueId",
15
-
"owner",
16
-
"title",
17
-
"createdAt"
18
-
],
12
+
"required": ["repo", "title", "createdAt"],
19
13
"properties": {
20
14
"repo": {
21
15
"type": "string",
22
16
"format": "at-uri"
23
17
},
24
-
"issueId": {
25
-
"type": "integer"
26
-
},
27
-
"owner": {
28
-
"type": "string",
29
-
"format": "did"
30
-
},
31
18
"title": {
32
19
"type": "string"
33
20
},
+16
nix/modules/spindle.nix
+16
nix/modules/spindle.nix
···
55
55
description = "DID of owner (required)";
56
56
};
57
57
58
+
maxJobCount = mkOption {
59
+
type = types.int;
60
+
default = 2;
61
+
example = 5;
62
+
description = "Maximum number of concurrent jobs to run";
63
+
};
64
+
65
+
queueSize = mkOption {
66
+
type = types.int;
67
+
default = 100;
68
+
example = 100;
69
+
description = "Maximum number of jobs queue up";
70
+
};
71
+
58
72
secrets = {
59
73
provider = mkOption {
60
74
type = types.str;
···
108
122
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
109
123
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
110
124
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
125
+
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
126
+
"SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}"
111
127
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
112
128
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
113
129
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
+4
appview/pages/templates/repo/fragments/duration.html
+4
appview/pages/templates/repo/fragments/duration.html
+4
appview/pages/templates/repo/fragments/shortTime.html
+4
appview/pages/templates/repo/fragments/shortTime.html
-16
appview/pages/templates/repo/fragments/time.html
-16
appview/pages/templates/repo/fragments/time.html
···
1
-
{{ define "repo/fragments/timeWrapper" }}
2
-
<time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time>
3
-
{{ end }}
4
-
5
1
{{ define "repo/fragments/time" }}
6
2
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }}
7
3
{{ end }}
8
-
9
-
{{ define "repo/fragments/shortTime" }}
10
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }}
11
-
{{ end }}
12
-
13
-
{{ define "repo/fragments/shortTimeAgo" }}
14
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
15
-
{{ end }}
16
-
17
-
{{ define "repo/fragments/duration" }}
18
-
<time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time>
19
-
{{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
+5
appview/pages/templates/repo/fragments/timeWrapper.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
···
52
52
</div>
53
53
{{ end }}
54
54
<div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2">
55
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
55
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
56
56
</div>
57
57
</div>
58
58
</a>
+35
appview/pages/cache.go
+35
appview/pages/cache.go
···
1
+
package pages
2
+
3
+
import (
4
+
"sync"
5
+
)
6
+
7
+
type TmplCache[K comparable, V any] struct {
8
+
data map[K]V
9
+
mutex sync.RWMutex
10
+
}
11
+
12
+
func NewTmplCache[K comparable, V any]() *TmplCache[K, V] {
13
+
return &TmplCache[K, V]{
14
+
data: make(map[K]V),
15
+
}
16
+
}
17
+
18
+
func (c *TmplCache[K, V]) Get(key K) (V, bool) {
19
+
c.mutex.RLock()
20
+
defer c.mutex.RUnlock()
21
+
val, exists := c.data[key]
22
+
return val, exists
23
+
}
24
+
25
+
func (c *TmplCache[K, V]) Set(key K, value V) {
26
+
c.mutex.Lock()
27
+
defer c.mutex.Unlock()
28
+
c.data[key] = value
29
+
}
30
+
31
+
func (c *TmplCache[K, V]) Size() int {
32
+
c.mutex.RLock()
33
+
defer c.mutex.RUnlock()
34
+
return len(c.data)
35
+
}
+1
-1
appview/pages/templates/user/fragments/editBio.html
+1
-1
appview/pages/templates/user/fragments/editBio.html
+7
-18
appview/pages/templates/user/repos.html
+7
-18
appview/pages/templates/user/repos.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · repos {{ end }}
2
2
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "ownRepos" . }}{{ end }}
17
-
</div>
18
-
</div>
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "ownRepos" . }}{{ end }}
6
+
</div>
19
7
{{ end }}
20
8
21
9
{{ define "ownRepos" }}
22
-
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
23
10
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
24
11
{{ range .Repos }}
25
-
{{ template "user/fragments/repoCard" (list $ . false) }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "user/fragments/repoCard" (list $ . false) }}
14
+
</div>
26
15
{{ else }}
27
16
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
28
17
{{ end }}
+19
appview/pages/templates/user/starred.html
+19
appview/pages/templates/user/starred.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · repos {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "starredRepos" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "starredRepos" }}
10
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
11
+
{{ range .Repos }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "user/fragments/repoCard" (list $ . true) }}
14
+
</div>
15
+
{{ else }}
16
+
<p class="px-6 dark:text-white">This user does not have any starred repos yet.</p>
17
+
{{ end }}
18
+
</div>
19
+
{{ end }}
+2
-2
appview/pages/templates/user/fragments/profileCard.html
+2
-2
appview/pages/templates/user/fragments/profileCard.html
···
92
92
{{ with $root }}
93
93
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
94
94
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
95
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
95
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span>
96
96
<span class="select-none after:content-['·']"></span>
97
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
97
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span>
98
98
</div>
99
99
{{ end }}
100
100
{{ end }}
+45
appview/pages/templates/user/strings.html
+45
appview/pages/templates/user/strings.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · strings {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "allStrings" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "allStrings" }}
10
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
11
+
{{ range .Strings }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "singleString" (list $ .) }}
14
+
</div>
15
+
{{ else }}
16
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
17
+
{{ end }}
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ define "singleString" }}
22
+
{{ $root := index . 0 }}
23
+
{{ $s := index . 1 }}
24
+
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
25
+
<div class="font-medium dark:text-white flex gap-2 items-center">
26
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
27
+
</div>
28
+
{{ with $s.Description }}
29
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
30
+
{{ . }}
31
+
</div>
32
+
{{ end }}
33
+
34
+
{{ $stat := $s.Stats }}
35
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
36
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
37
+
<span class="select-none [&:before]:content-['·']"></span>
38
+
{{ with $s.Edited }}
39
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
40
+
{{ else }}
41
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
42
+
{{ end }}
43
+
</div>
44
+
</div>
45
+
{{ end }}
+22
-2
knotserver/routes.go
+22
-2
knotserver/routes.go
···
156
156
}
157
157
158
158
var modVer string
159
+
var sha string
160
+
var modified bool
161
+
159
162
for _, mod := range info.Deps {
160
163
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
161
-
version = mod.Version
164
+
modVer = mod.Version
162
165
break
163
166
}
164
167
}
165
168
169
+
for _, setting := range info.Settings {
170
+
switch setting.Key {
171
+
case "vcs.revision":
172
+
sha = setting.Value
173
+
case "vcs.modified":
174
+
modified = setting.Value == "true"
175
+
}
176
+
}
177
+
166
178
if modVer == "" {
167
-
version = "unknown"
179
+
modVer = "unknown"
180
+
}
181
+
182
+
if sha == "" {
183
+
version = modVer
184
+
} else if modified {
185
+
version = fmt.Sprintf("%s (%s with modifications)", modVer, sha)
186
+
} else {
187
+
version = fmt.Sprintf("%s (%s)", modVer, sha)
168
188
}
169
189
}
170
190
+1
-1
patchutil/combinediff.go
+1
-1
patchutil/combinediff.go
+1
-1
appview/pages/templates/errors/knot404.html
+1
-1
appview/pages/templates/errors/knot404.html
···
17
17
The repository you were looking for could not be found. The knot serving the repository may be unavailable.
18
18
</p>
19
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline">
20
+
<a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline">
21
21
{{ i "arrow-left" "w-4 h-4" }}
22
22
back to timeline
23
23
</a>
+25
appview/pages/templates/timeline/fragments/trending.html
+25
appview/pages/templates/timeline/fragments/trending.html
···
1
+
{{ define "timeline/fragments/trending" }}
2
+
<div class="w-full md:mx-0 py-4">
3
+
<div class="px-6 pb-4">
4
+
<h3 class="text-xl font-bold dark:text-white flex items-center gap-2">
5
+
Trending
6
+
{{ i "trending-up" "size-4 flex-shrink-0" }}
7
+
</h3>
8
+
</div>
9
+
<div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch">
10
+
{{ range $index, $repo := .Repos }}
11
+
<div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96">
12
+
{{ template "user/fragments/repoCard" (list $ $repo true) }}
13
+
</div>
14
+
{{ else }}
15
+
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
16
+
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
17
+
No trending repositories this week
18
+
</div>
19
+
</div>
20
+
{{ end }}
21
+
</div>
22
+
</div>
23
+
{{ end }}
24
+
25
+
+109
legal/terms.md
+109
legal/terms.md
···
1
+
# Terms of Service
2
+
3
+
**Last updated:** January 15, 2025
4
+
5
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
6
+
to and use of the Tangled platform and services (the "Service")
7
+
operated by us ("Tangled," "we," "us," or "our").
8
+
9
+
## 1. Acceptance of Terms
10
+
11
+
By accessing or using our Service, you agree to be bound by these Terms.
12
+
If you disagree with any part of these terms, then you may not access
13
+
the Service.
14
+
15
+
## 2. Account Registration
16
+
17
+
To use certain features of the Service, you must register for an
18
+
account. You agree to provide accurate, current, and complete
19
+
information during the registration process and to update such
20
+
information to keep it accurate, current, and complete.
21
+
22
+
## 3. Account Termination
23
+
24
+
> **Important Notice**
25
+
>
26
+
> **We reserve the right to terminate, suspend, or restrict access to
27
+
> your account at any time, for any reason, or for no reason at all, at
28
+
> our sole discretion.** This includes, but is not limited to,
29
+
> termination for violation of these Terms, inappropriate conduct, spam,
30
+
> abuse, or any other behavior we deem harmful to the Service or other
31
+
> users.
32
+
>
33
+
> Account termination may result in the loss of access to your
34
+
> repositories, data, and other content associated with your account. We
35
+
> are not obligated to provide advance notice of termination, though we
36
+
> may do so in our discretion.
37
+
38
+
## 4. Acceptable Use
39
+
40
+
You agree not to use the Service to:
41
+
42
+
- Violate any applicable laws or regulations
43
+
- Infringe upon the rights of others
44
+
- Upload, store, or share content that is illegal, harmful, threatening,
45
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
46
+
objectionable
47
+
- Engage in spam, phishing, or other deceptive practices
48
+
- Attempt to gain unauthorized access to the Service or other users'
49
+
accounts
50
+
- Interfere with or disrupt the Service or servers connected to the
51
+
Service
52
+
53
+
## 5. Content and Intellectual Property
54
+
55
+
You retain ownership of the content you upload to the Service. By
56
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
57
+
license to use, reproduce, modify, and distribute your content as
58
+
necessary to provide the Service.
59
+
60
+
## 6. Privacy
61
+
62
+
Your privacy is important to us. Please review our [Privacy
63
+
Policy](/privacy), which also governs your use of the Service.
64
+
65
+
## 7. Disclaimers
66
+
67
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
68
+
no warranties, expressed or implied, and hereby disclaim and negate all
69
+
other warranties including without limitation, implied warranties or
70
+
conditions of merchantability, fitness for a particular purpose, or
71
+
non-infringement of intellectual property or other violation of rights.
72
+
73
+
## 8. Limitation of Liability
74
+
75
+
In no event shall Tangled, nor its directors, employees, partners,
76
+
agents, suppliers, or affiliates, be liable for any indirect,
77
+
incidental, special, consequential, or punitive damages, including
78
+
without limitation, loss of profits, data, use, goodwill, or other
79
+
intangible losses, resulting from your use of the Service.
80
+
81
+
## 9. Indemnification
82
+
83
+
You agree to defend, indemnify, and hold harmless Tangled and its
84
+
affiliates, officers, directors, employees, and agents from and against
85
+
any and all claims, damages, obligations, losses, liabilities, costs,
86
+
or debt, and expenses (including attorney's fees).
87
+
88
+
## 10. Governing Law
89
+
90
+
These Terms shall be interpreted and governed by the laws of Finland,
91
+
without regard to its conflict of law provisions.
92
+
93
+
## 11. Changes to Terms
94
+
95
+
We reserve the right to modify or replace these Terms at any time. If a
96
+
revision is material, we will try to provide at least 30 days notice
97
+
prior to any new terms taking effect.
98
+
99
+
## 12. Contact Information
100
+
101
+
If you have any questions about these Terms of Service, please contact
102
+
us through our platform or via email.
103
+
104
+
---
105
+
106
+
These terms are effective as of the last updated date shown above and
107
+
will remain in effect except with respect to any changes in their
108
+
provisions in the future, which will be in effect immediately after
109
+
being posted on this page.
+53
api/tangled/knotlistKeys.go
+53
api/tangled/knotlistKeys.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.knot.listKeys
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
KnotListKeysNSID = "sh.tangled.knot.listKeys"
15
+
)
16
+
17
+
// KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call.
18
+
type KnotListKeys_Output struct {
19
+
// cursor: Pagination cursor for next page
20
+
Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
21
+
Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"`
22
+
}
23
+
24
+
// KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema.
25
+
type KnotListKeys_PublicKey struct {
26
+
// createdAt: Key upload timestamp
27
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
28
+
// did: DID associated with the public key
29
+
Did string `json:"did" cborgen:"did"`
30
+
// key: Public key contents
31
+
Key string `json:"key" cborgen:"key"`
32
+
}
33
+
34
+
// KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys".
35
+
//
36
+
// cursor: Pagination cursor
37
+
// limit: Maximum number of keys to return
38
+
func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) {
39
+
var out KnotListKeys_Output
40
+
41
+
params := map[string]interface{}{}
42
+
if cursor != "" {
43
+
params["cursor"] = cursor
44
+
}
45
+
if limit != 0 {
46
+
params["limit"] = limit
47
+
}
48
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil {
49
+
return nil, err
50
+
}
51
+
52
+
return &out, nil
53
+
}
+41
api/tangled/repoarchive.go
+41
api/tangled/repoarchive.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.archive
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoArchiveNSID = "sh.tangled.repo.archive"
16
+
)
17
+
18
+
// RepoArchive calls the XRPC method "sh.tangled.repo.archive".
19
+
//
20
+
// format: Archive format
21
+
// prefix: Prefix for files in the archive
22
+
// ref: Git reference (branch, tag, or commit SHA)
23
+
// repo: Repository identifier in format 'did:plc:.../repoName'
24
+
func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) {
25
+
buf := new(bytes.Buffer)
26
+
27
+
params := map[string]interface{}{}
28
+
if format != "" {
29
+
params["format"] = format
30
+
}
31
+
if prefix != "" {
32
+
params["prefix"] = prefix
33
+
}
34
+
params["ref"] = ref
35
+
params["repo"] = repo
36
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil {
37
+
return nil, err
38
+
}
39
+
40
+
return buf.Bytes(), nil
41
+
}
+80
api/tangled/repoblob.go
+80
api/tangled/repoblob.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.blob
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoBlobNSID = "sh.tangled.repo.blob"
15
+
)
16
+
17
+
// RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema.
18
+
type RepoBlob_LastCommit struct {
19
+
Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20
+
// hash: Commit hash
21
+
Hash string `json:"hash" cborgen:"hash"`
22
+
// message: Commit message
23
+
Message string `json:"message" cborgen:"message"`
24
+
// shortHash: Short commit hash
25
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
26
+
// when: Commit timestamp
27
+
When string `json:"when" cborgen:"when"`
28
+
}
29
+
30
+
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
+
type RepoBlob_Output struct {
32
+
// content: File content (base64 encoded for binary files)
33
+
Content string `json:"content" cborgen:"content"`
34
+
// encoding: Content encoding
35
+
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
+
// isBinary: Whether the file is binary
37
+
IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"`
38
+
LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"`
39
+
// mimeType: MIME type of the file
40
+
MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"`
41
+
// path: The file path
42
+
Path string `json:"path" cborgen:"path"`
43
+
// ref: The git reference used
44
+
Ref string `json:"ref" cborgen:"ref"`
45
+
// size: File size in bytes
46
+
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
47
+
}
48
+
49
+
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
50
+
type RepoBlob_Signature struct {
51
+
// email: Author email
52
+
Email string `json:"email" cborgen:"email"`
53
+
// name: Author name
54
+
Name string `json:"name" cborgen:"name"`
55
+
// when: Author timestamp
56
+
When string `json:"when" cborgen:"when"`
57
+
}
58
+
59
+
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
60
+
//
61
+
// path: Path to the file within the repository
62
+
// raw: Return raw file content instead of JSON response
63
+
// ref: Git reference (branch, tag, or commit SHA)
64
+
// repo: Repository identifier in format 'did:plc:.../repoName'
65
+
func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) {
66
+
var out RepoBlob_Output
67
+
68
+
params := map[string]interface{}{}
69
+
params["path"] = path
70
+
if raw {
71
+
params["raw"] = raw
72
+
}
73
+
params["ref"] = ref
74
+
params["repo"] = repo
75
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil {
76
+
return nil, err
77
+
}
78
+
79
+
return &out, nil
80
+
}
+59
api/tangled/repobranch.go
+59
api/tangled/repobranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.branch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoBranchNSID = "sh.tangled.repo.branch"
15
+
)
16
+
17
+
// RepoBranch_Output is the output of a sh.tangled.repo.branch call.
18
+
type RepoBranch_Output struct {
19
+
Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20
+
// hash: Latest commit hash on this branch
21
+
Hash string `json:"hash" cborgen:"hash"`
22
+
// isDefault: Whether this is the default branch
23
+
IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"`
24
+
// message: Latest commit message
25
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
26
+
// name: Branch name
27
+
Name string `json:"name" cborgen:"name"`
28
+
// shortHash: Short commit hash
29
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
30
+
// when: Timestamp of latest commit
31
+
When string `json:"when" cborgen:"when"`
32
+
}
33
+
34
+
// RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema.
35
+
type RepoBranch_Signature struct {
36
+
// email: Author email
37
+
Email string `json:"email" cborgen:"email"`
38
+
// name: Author name
39
+
Name string `json:"name" cborgen:"name"`
40
+
// when: Author timestamp
41
+
When string `json:"when" cborgen:"when"`
42
+
}
43
+
44
+
// RepoBranch calls the XRPC method "sh.tangled.repo.branch".
45
+
//
46
+
// name: Branch name to get information for
47
+
// repo: Repository identifier in format 'did:plc:.../repoName'
48
+
func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) {
49
+
var out RepoBranch_Output
50
+
51
+
params := map[string]interface{}{}
52
+
params["name"] = name
53
+
params["repo"] = repo
54
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
return &out, nil
59
+
}
+39
api/tangled/repobranches.go
+39
api/tangled/repobranches.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.branches
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoBranchesNSID = "sh.tangled.repo.branches"
16
+
)
17
+
18
+
// RepoBranches calls the XRPC method "sh.tangled.repo.branches".
19
+
//
20
+
// cursor: Pagination cursor
21
+
// limit: Maximum number of branches to return
22
+
// repo: Repository identifier in format 'did:plc:.../repoName'
23
+
func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
24
+
buf := new(bytes.Buffer)
25
+
26
+
params := map[string]interface{}{}
27
+
if cursor != "" {
28
+
params["cursor"] = cursor
29
+
}
30
+
if limit != 0 {
31
+
params["limit"] = limit
32
+
}
33
+
params["repo"] = repo
34
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil {
35
+
return nil, err
36
+
}
37
+
38
+
return buf.Bytes(), nil
39
+
}
+35
api/tangled/repocompare.go
+35
api/tangled/repocompare.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.compare
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoCompareNSID = "sh.tangled.repo.compare"
16
+
)
17
+
18
+
// RepoCompare calls the XRPC method "sh.tangled.repo.compare".
19
+
//
20
+
// repo: Repository identifier in format 'did:plc:.../repoName'
21
+
// rev1: First revision (commit, branch, or tag)
22
+
// rev2: Second revision (commit, branch, or tag)
23
+
func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) {
24
+
buf := new(bytes.Buffer)
25
+
26
+
params := map[string]interface{}{}
27
+
params["repo"] = repo
28
+
params["rev1"] = rev1
29
+
params["rev2"] = rev2
30
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil {
31
+
return nil, err
32
+
}
33
+
34
+
return buf.Bytes(), nil
35
+
}
+33
api/tangled/repodiff.go
+33
api/tangled/repodiff.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.diff
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoDiffNSID = "sh.tangled.repo.diff"
16
+
)
17
+
18
+
// RepoDiff calls the XRPC method "sh.tangled.repo.diff".
19
+
//
20
+
// ref: Git reference (branch, tag, or commit SHA)
21
+
// repo: Repository identifier in format 'did:plc:.../repoName'
22
+
func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) {
23
+
buf := new(bytes.Buffer)
24
+
25
+
params := map[string]interface{}{}
26
+
params["ref"] = ref
27
+
params["repo"] = repo
28
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil {
29
+
return nil, err
30
+
}
31
+
32
+
return buf.Bytes(), nil
33
+
}
+55
api/tangled/repogetDefaultBranch.go
+55
api/tangled/repogetDefaultBranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.getDefaultBranch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch"
15
+
)
16
+
17
+
// RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call.
18
+
type RepoGetDefaultBranch_Output struct {
19
+
Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20
+
// hash: Latest commit hash on default branch
21
+
Hash string `json:"hash" cborgen:"hash"`
22
+
// message: Latest commit message
23
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
24
+
// name: Default branch name
25
+
Name string `json:"name" cborgen:"name"`
26
+
// shortHash: Short commit hash
27
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
28
+
// when: Timestamp of latest commit
29
+
When string `json:"when" cborgen:"when"`
30
+
}
31
+
32
+
// RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema.
33
+
type RepoGetDefaultBranch_Signature struct {
34
+
// email: Author email
35
+
Email string `json:"email" cborgen:"email"`
36
+
// name: Author name
37
+
Name string `json:"name" cborgen:"name"`
38
+
// when: Author timestamp
39
+
When string `json:"when" cborgen:"when"`
40
+
}
41
+
42
+
// RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch".
43
+
//
44
+
// repo: Repository identifier in format 'did:plc:.../repoName'
45
+
func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) {
46
+
var out RepoGetDefaultBranch_Output
47
+
48
+
params := map[string]interface{}{}
49
+
params["repo"] = repo
50
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil {
51
+
return nil, err
52
+
}
53
+
54
+
return &out, nil
55
+
}
+61
api/tangled/repolanguages.go
+61
api/tangled/repolanguages.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.languages
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoLanguagesNSID = "sh.tangled.repo.languages"
15
+
)
16
+
17
+
// RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema.
18
+
type RepoLanguages_Language struct {
19
+
// color: Hex color code for this language
20
+
Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
21
+
// extensions: File extensions associated with this language
22
+
Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"`
23
+
// fileCount: Number of files in this language
24
+
FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"`
25
+
// name: Programming language name
26
+
Name string `json:"name" cborgen:"name"`
27
+
// percentage: Percentage of total codebase (0-100)
28
+
Percentage int64 `json:"percentage" cborgen:"percentage"`
29
+
// size: Total size of files in this language (bytes)
30
+
Size int64 `json:"size" cborgen:"size"`
31
+
}
32
+
33
+
// RepoLanguages_Output is the output of a sh.tangled.repo.languages call.
34
+
type RepoLanguages_Output struct {
35
+
Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"`
36
+
// ref: The git reference used
37
+
Ref string `json:"ref" cborgen:"ref"`
38
+
// totalFiles: Total number of files analyzed
39
+
TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"`
40
+
// totalSize: Total size of all analyzed files in bytes
41
+
TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"`
42
+
}
43
+
44
+
// RepoLanguages calls the XRPC method "sh.tangled.repo.languages".
45
+
//
46
+
// ref: Git reference (branch, tag, or commit SHA)
47
+
// repo: Repository identifier in format 'did:plc:.../repoName'
48
+
func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) {
49
+
var out RepoLanguages_Output
50
+
51
+
params := map[string]interface{}{}
52
+
if ref != "" {
53
+
params["ref"] = ref
54
+
}
55
+
params["repo"] = repo
56
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil {
57
+
return nil, err
58
+
}
59
+
60
+
return &out, nil
61
+
}
+45
api/tangled/repolog.go
+45
api/tangled/repolog.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.log
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoLogNSID = "sh.tangled.repo.log"
16
+
)
17
+
18
+
// RepoLog calls the XRPC method "sh.tangled.repo.log".
19
+
//
20
+
// cursor: Pagination cursor (commit SHA)
21
+
// limit: Maximum number of commits to return
22
+
// path: Path to filter commits by
23
+
// ref: Git reference (branch, tag, or commit SHA)
24
+
// repo: Repository identifier in format 'did:plc:.../repoName'
25
+
func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) {
26
+
buf := new(bytes.Buffer)
27
+
28
+
params := map[string]interface{}{}
29
+
if cursor != "" {
30
+
params["cursor"] = cursor
31
+
}
32
+
if limit != 0 {
33
+
params["limit"] = limit
34
+
}
35
+
if path != "" {
36
+
params["path"] = path
37
+
}
38
+
params["ref"] = ref
39
+
params["repo"] = repo
40
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil {
41
+
return nil, err
42
+
}
43
+
44
+
return buf.Bytes(), nil
45
+
}
+73
lexicons/knot/listKeys.json
+73
lexicons/knot/listKeys.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.knot.listKeys",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "List all public keys stored in the knot server",
8
+
"parameters": {
9
+
"type": "params",
10
+
"properties": {
11
+
"limit": {
12
+
"type": "integer",
13
+
"description": "Maximum number of keys to return",
14
+
"minimum": 1,
15
+
"maximum": 1000,
16
+
"default": 100
17
+
},
18
+
"cursor": {
19
+
"type": "string",
20
+
"description": "Pagination cursor"
21
+
}
22
+
}
23
+
},
24
+
"output": {
25
+
"encoding": "application/json",
26
+
"schema": {
27
+
"type": "object",
28
+
"required": ["keys"],
29
+
"properties": {
30
+
"keys": {
31
+
"type": "array",
32
+
"items": {
33
+
"type": "ref",
34
+
"ref": "#publicKey"
35
+
}
36
+
},
37
+
"cursor": {
38
+
"type": "string",
39
+
"description": "Pagination cursor for next page"
40
+
}
41
+
}
42
+
}
43
+
},
44
+
"errors": [
45
+
{
46
+
"name": "InternalServerError",
47
+
"description": "Failed to retrieve public keys"
48
+
}
49
+
]
50
+
},
51
+
"publicKey": {
52
+
"type": "object",
53
+
"required": ["did", "key", "createdAt"],
54
+
"properties": {
55
+
"did": {
56
+
"type": "string",
57
+
"format": "did",
58
+
"description": "DID associated with the public key"
59
+
},
60
+
"key": {
61
+
"type": "string",
62
+
"maxLength": 4096,
63
+
"description": "Public key contents"
64
+
},
65
+
"createdAt": {
66
+
"type": "string",
67
+
"format": "datetime",
68
+
"description": "Key upload timestamp"
69
+
}
70
+
}
71
+
}
72
+
}
73
+
}
+55
lexicons/repo/archive.json
+55
lexicons/repo/archive.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.archive",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"format": {
20
+
"type": "string",
21
+
"description": "Archive format",
22
+
"enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"],
23
+
"default": "tar.gz"
24
+
},
25
+
"prefix": {
26
+
"type": "string",
27
+
"description": "Prefix for files in the archive"
28
+
}
29
+
}
30
+
},
31
+
"output": {
32
+
"encoding": "*/*",
33
+
"description": "Binary archive data"
34
+
},
35
+
"errors": [
36
+
{
37
+
"name": "RepoNotFound",
38
+
"description": "Repository not found or access denied"
39
+
},
40
+
{
41
+
"name": "RefNotFound",
42
+
"description": "Git reference not found"
43
+
},
44
+
{
45
+
"name": "InvalidRequest",
46
+
"description": "Invalid request parameters"
47
+
},
48
+
{
49
+
"name": "ArchiveError",
50
+
"description": "Failed to create archive"
51
+
}
52
+
]
53
+
}
54
+
}
55
+
}
+138
lexicons/repo/blob.json
+138
lexicons/repo/blob.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.blob",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref", "path"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"path": {
20
+
"type": "string",
21
+
"description": "Path to the file within the repository"
22
+
},
23
+
"raw": {
24
+
"type": "boolean",
25
+
"description": "Return raw file content instead of JSON response",
26
+
"default": false
27
+
}
28
+
}
29
+
},
30
+
"output": {
31
+
"encoding": "application/json",
32
+
"schema": {
33
+
"type": "object",
34
+
"required": ["ref", "path", "content"],
35
+
"properties": {
36
+
"ref": {
37
+
"type": "string",
38
+
"description": "The git reference used"
39
+
},
40
+
"path": {
41
+
"type": "string",
42
+
"description": "The file path"
43
+
},
44
+
"content": {
45
+
"type": "string",
46
+
"description": "File content (base64 encoded for binary files)"
47
+
},
48
+
"encoding": {
49
+
"type": "string",
50
+
"description": "Content encoding",
51
+
"enum": ["utf-8", "base64"]
52
+
},
53
+
"size": {
54
+
"type": "integer",
55
+
"description": "File size in bytes"
56
+
},
57
+
"isBinary": {
58
+
"type": "boolean",
59
+
"description": "Whether the file is binary"
60
+
},
61
+
"mimeType": {
62
+
"type": "string",
63
+
"description": "MIME type of the file"
64
+
},
65
+
"lastCommit": {
66
+
"type": "ref",
67
+
"ref": "#lastCommit"
68
+
}
69
+
}
70
+
}
71
+
},
72
+
"errors": [
73
+
{
74
+
"name": "RepoNotFound",
75
+
"description": "Repository not found or access denied"
76
+
},
77
+
{
78
+
"name": "RefNotFound",
79
+
"description": "Git reference not found"
80
+
},
81
+
{
82
+
"name": "FileNotFound",
83
+
"description": "File not found at the specified path"
84
+
},
85
+
{
86
+
"name": "InvalidRequest",
87
+
"description": "Invalid request parameters"
88
+
}
89
+
]
90
+
},
91
+
"lastCommit": {
92
+
"type": "object",
93
+
"required": ["hash", "message", "when"],
94
+
"properties": {
95
+
"hash": {
96
+
"type": "string",
97
+
"description": "Commit hash"
98
+
},
99
+
"shortHash": {
100
+
"type": "string",
101
+
"description": "Short commit hash"
102
+
},
103
+
"message": {
104
+
"type": "string",
105
+
"description": "Commit message"
106
+
},
107
+
"author": {
108
+
"type": "ref",
109
+
"ref": "#signature"
110
+
},
111
+
"when": {
112
+
"type": "string",
113
+
"format": "datetime",
114
+
"description": "Commit timestamp"
115
+
}
116
+
}
117
+
},
118
+
"signature": {
119
+
"type": "object",
120
+
"required": ["name", "email", "when"],
121
+
"properties": {
122
+
"name": {
123
+
"type": "string",
124
+
"description": "Author name"
125
+
},
126
+
"email": {
127
+
"type": "string",
128
+
"description": "Author email"
129
+
},
130
+
"when": {
131
+
"type": "string",
132
+
"format": "datetime",
133
+
"description": "Author timestamp"
134
+
}
135
+
}
136
+
}
137
+
}
138
+
}
+94
lexicons/repo/branch.json
+94
lexicons/repo/branch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.branch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "name"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"name": {
16
+
"type": "string",
17
+
"description": "Branch name to get information for"
18
+
}
19
+
}
20
+
},
21
+
"output": {
22
+
"encoding": "application/json",
23
+
"schema": {
24
+
"type": "object",
25
+
"required": ["name", "hash", "when"],
26
+
"properties": {
27
+
"name": {
28
+
"type": "string",
29
+
"description": "Branch name"
30
+
},
31
+
"hash": {
32
+
"type": "string",
33
+
"description": "Latest commit hash on this branch"
34
+
},
35
+
"shortHash": {
36
+
"type": "string",
37
+
"description": "Short commit hash"
38
+
},
39
+
"when": {
40
+
"type": "string",
41
+
"format": "datetime",
42
+
"description": "Timestamp of latest commit"
43
+
},
44
+
"message": {
45
+
"type": "string",
46
+
"description": "Latest commit message"
47
+
},
48
+
"author": {
49
+
"type": "ref",
50
+
"ref": "#signature"
51
+
},
52
+
"isDefault": {
53
+
"type": "boolean",
54
+
"description": "Whether this is the default branch"
55
+
}
56
+
}
57
+
}
58
+
},
59
+
"errors": [
60
+
{
61
+
"name": "RepoNotFound",
62
+
"description": "Repository not found or access denied"
63
+
},
64
+
{
65
+
"name": "BranchNotFound",
66
+
"description": "Branch not found"
67
+
},
68
+
{
69
+
"name": "InvalidRequest",
70
+
"description": "Invalid request parameters"
71
+
}
72
+
]
73
+
},
74
+
"signature": {
75
+
"type": "object",
76
+
"required": ["name", "email", "when"],
77
+
"properties": {
78
+
"name": {
79
+
"type": "string",
80
+
"description": "Author name"
81
+
},
82
+
"email": {
83
+
"type": "string",
84
+
"description": "Author email"
85
+
},
86
+
"when": {
87
+
"type": "string",
88
+
"format": "datetime",
89
+
"description": "Author timestamp"
90
+
}
91
+
}
92
+
}
93
+
}
94
+
}
+43
lexicons/repo/branches.json
+43
lexicons/repo/branches.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.branches",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"limit": {
16
+
"type": "integer",
17
+
"description": "Maximum number of branches to return",
18
+
"minimum": 1,
19
+
"maximum": 100,
20
+
"default": 50
21
+
},
22
+
"cursor": {
23
+
"type": "string",
24
+
"description": "Pagination cursor"
25
+
}
26
+
}
27
+
},
28
+
"output": {
29
+
"encoding": "*/*"
30
+
},
31
+
"errors": [
32
+
{
33
+
"name": "RepoNotFound",
34
+
"description": "Repository not found or access denied"
35
+
},
36
+
{
37
+
"name": "InvalidRequest",
38
+
"description": "Invalid request parameters"
39
+
}
40
+
]
41
+
}
42
+
}
43
+
}
+49
lexicons/repo/compare.json
+49
lexicons/repo/compare.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.compare",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "rev1", "rev2"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"rev1": {
16
+
"type": "string",
17
+
"description": "First revision (commit, branch, or tag)"
18
+
},
19
+
"rev2": {
20
+
"type": "string",
21
+
"description": "Second revision (commit, branch, or tag)"
22
+
}
23
+
}
24
+
},
25
+
"output": {
26
+
"encoding": "*/*",
27
+
"description": "Compare output in application/json"
28
+
},
29
+
"errors": [
30
+
{
31
+
"name": "RepoNotFound",
32
+
"description": "Repository not found or access denied"
33
+
},
34
+
{
35
+
"name": "RevisionNotFound",
36
+
"description": "One or both revisions not found"
37
+
},
38
+
{
39
+
"name": "InvalidRequest",
40
+
"description": "Invalid request parameters"
41
+
},
42
+
{
43
+
"name": "CompareError",
44
+
"description": "Failed to compare revisions"
45
+
}
46
+
]
47
+
}
48
+
}
49
+
}
+40
lexicons/repo/diff.json
+40
lexicons/repo/diff.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.diff",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
}
19
+
}
20
+
},
21
+
"output": {
22
+
"encoding": "*/*"
23
+
},
24
+
"errors": [
25
+
{
26
+
"name": "RepoNotFound",
27
+
"description": "Repository not found or access denied"
28
+
},
29
+
{
30
+
"name": "RefNotFound",
31
+
"description": "Git reference not found"
32
+
},
33
+
{
34
+
"name": "InvalidRequest",
35
+
"description": "Invalid request parameters"
36
+
}
37
+
]
38
+
}
39
+
}
40
+
}
+82
lexicons/repo/getDefaultBranch.json
+82
lexicons/repo/getDefaultBranch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.getDefaultBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
}
15
+
}
16
+
},
17
+
"output": {
18
+
"encoding": "application/json",
19
+
"schema": {
20
+
"type": "object",
21
+
"required": ["name", "hash", "when"],
22
+
"properties": {
23
+
"name": {
24
+
"type": "string",
25
+
"description": "Default branch name"
26
+
},
27
+
"hash": {
28
+
"type": "string",
29
+
"description": "Latest commit hash on default branch"
30
+
},
31
+
"shortHash": {
32
+
"type": "string",
33
+
"description": "Short commit hash"
34
+
},
35
+
"when": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"description": "Timestamp of latest commit"
39
+
},
40
+
"message": {
41
+
"type": "string",
42
+
"description": "Latest commit message"
43
+
},
44
+
"author": {
45
+
"type": "ref",
46
+
"ref": "#signature"
47
+
}
48
+
}
49
+
}
50
+
},
51
+
"errors": [
52
+
{
53
+
"name": "RepoNotFound",
54
+
"description": "Repository not found or access denied"
55
+
},
56
+
{
57
+
"name": "InvalidRequest",
58
+
"description": "Invalid request parameters"
59
+
}
60
+
]
61
+
},
62
+
"signature": {
63
+
"type": "object",
64
+
"required": ["name", "email", "when"],
65
+
"properties": {
66
+
"name": {
67
+
"type": "string",
68
+
"description": "Author name"
69
+
},
70
+
"email": {
71
+
"type": "string",
72
+
"description": "Author email"
73
+
},
74
+
"when": {
75
+
"type": "string",
76
+
"format": "datetime",
77
+
"description": "Author timestamp"
78
+
}
79
+
}
80
+
}
81
+
}
82
+
}
+99
lexicons/repo/languages.json
+99
lexicons/repo/languages.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.languages",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)",
18
+
"default": "HEAD"
19
+
}
20
+
}
21
+
},
22
+
"output": {
23
+
"encoding": "application/json",
24
+
"schema": {
25
+
"type": "object",
26
+
"required": ["ref", "languages"],
27
+
"properties": {
28
+
"ref": {
29
+
"type": "string",
30
+
"description": "The git reference used"
31
+
},
32
+
"languages": {
33
+
"type": "array",
34
+
"items": {
35
+
"type": "ref",
36
+
"ref": "#language"
37
+
}
38
+
},
39
+
"totalSize": {
40
+
"type": "integer",
41
+
"description": "Total size of all analyzed files in bytes"
42
+
},
43
+
"totalFiles": {
44
+
"type": "integer",
45
+
"description": "Total number of files analyzed"
46
+
}
47
+
}
48
+
}
49
+
},
50
+
"errors": [
51
+
{
52
+
"name": "RepoNotFound",
53
+
"description": "Repository not found or access denied"
54
+
},
55
+
{
56
+
"name": "RefNotFound",
57
+
"description": "Git reference not found"
58
+
},
59
+
{
60
+
"name": "InvalidRequest",
61
+
"description": "Invalid request parameters"
62
+
}
63
+
]
64
+
},
65
+
"language": {
66
+
"type": "object",
67
+
"required": ["name", "size", "percentage"],
68
+
"properties": {
69
+
"name": {
70
+
"type": "string",
71
+
"description": "Programming language name"
72
+
},
73
+
"size": {
74
+
"type": "integer",
75
+
"description": "Total size of files in this language (bytes)"
76
+
},
77
+
"percentage": {
78
+
"type": "integer",
79
+
"description": "Percentage of total codebase (0-100)"
80
+
},
81
+
"fileCount": {
82
+
"type": "integer",
83
+
"description": "Number of files in this language"
84
+
},
85
+
"color": {
86
+
"type": "string",
87
+
"description": "Hex color code for this language"
88
+
},
89
+
"extensions": {
90
+
"type": "array",
91
+
"items": {
92
+
"type": "string"
93
+
},
94
+
"description": "File extensions associated with this language"
95
+
}
96
+
}
97
+
}
98
+
}
99
+
}
+60
lexicons/repo/log.json
+60
lexicons/repo/log.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.log",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"path": {
20
+
"type": "string",
21
+
"description": "Path to filter commits by",
22
+
"default": ""
23
+
},
24
+
"limit": {
25
+
"type": "integer",
26
+
"description": "Maximum number of commits to return",
27
+
"minimum": 1,
28
+
"maximum": 100,
29
+
"default": 50
30
+
},
31
+
"cursor": {
32
+
"type": "string",
33
+
"description": "Pagination cursor (commit SHA)"
34
+
}
35
+
}
36
+
},
37
+
"output": {
38
+
"encoding": "*/*"
39
+
},
40
+
"errors": [
41
+
{
42
+
"name": "RepoNotFound",
43
+
"description": "Repository not found or access denied"
44
+
},
45
+
{
46
+
"name": "RefNotFound",
47
+
"description": "Git reference not found"
48
+
},
49
+
{
50
+
"name": "PathNotFound",
51
+
"description": "Path not found in repository"
52
+
},
53
+
{
54
+
"name": "InvalidRequest",
55
+
"description": "Invalid request parameters"
56
+
}
57
+
]
58
+
}
59
+
}
60
+
}
+30
api/tangled/tangledowner.go
+30
api/tangled/tangledowner.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.owner
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
OwnerNSID = "sh.tangled.owner"
15
+
)
16
+
17
+
// Owner_Output is the output of a sh.tangled.owner call.
18
+
type Owner_Output struct {
19
+
Owner string `json:"owner" cborgen:"owner"`
20
+
}
21
+
22
+
// Owner calls the XRPC method "sh.tangled.owner".
23
+
func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) {
24
+
var out Owner_Output
25
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil {
26
+
return nil, err
27
+
}
28
+
29
+
return &out, nil
30
+
}
+31
lexicons/owner.json
+31
lexicons/owner.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.owner",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Get the owner of a service",
8
+
"output": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"owner"
14
+
],
15
+
"properties": {
16
+
"owner": {
17
+
"type": "string",
18
+
"format": "did"
19
+
}
20
+
}
21
+
}
22
+
},
23
+
"errors": [
24
+
{
25
+
"name": "OwnerNotFound",
26
+
"description": "Owner is not set for this service"
27
+
}
28
+
]
29
+
}
30
+
}
31
+
}
+30
api/tangled/knotversion.go
+30
api/tangled/knotversion.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.knot.version
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
KnotVersionNSID = "sh.tangled.knot.version"
15
+
)
16
+
17
+
// KnotVersion_Output is the output of a sh.tangled.knot.version call.
18
+
type KnotVersion_Output struct {
19
+
Version string `json:"version" cborgen:"version"`
20
+
}
21
+
22
+
// KnotVersion calls the XRPC method "sh.tangled.knot.version".
23
+
func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) {
24
+
var out KnotVersion_Output
25
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil {
26
+
return nil, err
27
+
}
28
+
29
+
return &out, nil
30
+
}
+25
lexicons/knot/version.json
+25
lexicons/knot/version.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.knot.version",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Get the version of a knot",
8
+
"output": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"version"
14
+
],
15
+
"properties": {
16
+
"version": {
17
+
"type": "string"
18
+
}
19
+
}
20
+
}
21
+
},
22
+
"errors": []
23
+
}
24
+
}
25
+
}
+6
-1
appview/pages/templates/spindles/fragments/spindleListing.html
+6
-1
appview/pages/templates/spindles/fragments/spindleListing.html
···
30
30
{{ define "spindleRightSide" }}
31
31
<div id="right-side" class="flex gap-2">
32
32
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
33
-
{{ if .Verified }}
33
+
34
+
{{ if .NeedsUpgrade }}
35
+
<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>
36
+
{{ block "spindleRetryButton" . }} {{ end }}
37
+
{{ else if .Verified }}
34
38
<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>
35
39
{{ template "spindles/fragments/addMemberModal" . }}
36
40
{{ else }}
37
41
<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>
38
42
{{ block "spindleRetryButton" . }} {{ end }}
39
43
{{ end }}
44
+
40
45
{{ block "spindleDeleteButton" . }} {{ end }}
41
46
</div>
42
47
{{ end }}
+37
-45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+37
-45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
1
1
{{ define "repo/issues/fragments/editIssueComment" }}
2
-
{{ with .Comment }}
3
-
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
-
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
2
+
<div id="comment-body-{{.Comment.Id}}" class="pt-2">
3
+
<textarea
4
+
id="edit-textarea-{{ .Comment.Id }}"
5
+
name="body"
6
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
7
+
rows="5"
8
+
autofocus>{{ .Comment.Body }}</textarea>
7
9
8
-
<!-- show user "hats" -->
9
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
-
{{ if $isIssueAuthor }}
11
-
<span class="before:content-['·']"></span>
12
-
author
13
-
{{ end }}
14
-
15
-
<span class="before:content-['·']"></span>
16
-
<a
17
-
href="#{{ .CommentId }}"
18
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
19
-
id="{{ .CommentId }}">
20
-
{{ template "repo/fragments/time" .Created }}
21
-
</a>
22
-
23
-
<button
24
-
class="btn px-2 py-1 flex items-center gap-2 text-sm group"
25
-
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
26
-
hx-include="#edit-textarea-{{ .CommentId }}"
27
-
hx-target="#comment-container-{{ .CommentId }}"
28
-
hx-swap="outerHTML">
29
-
{{ i "check" "w-4 h-4" }}
30
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
-
</button>
32
-
<button
33
-
class="btn px-2 py-1 flex items-center gap-2 text-sm"
34
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
35
-
hx-target="#comment-container-{{ .CommentId }}"
36
-
hx-swap="outerHTML">
37
-
{{ i "x" "w-4 h-4" }}
38
-
</button>
39
-
<span id="comment-{{.CommentId}}-status"></span>
40
-
</div>
10
+
{{ template "editActions" $ }}
11
+
</div>
12
+
{{ end }}
41
13
42
-
<div>
43
-
<textarea
44
-
id="edit-textarea-{{ .CommentId }}"
45
-
name="body"
46
-
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
47
-
</div>
14
+
{{ define "editActions" }}
15
+
<div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2">
16
+
{{ template "cancel" . }}
17
+
{{ template "save" . }}
48
18
</div>
49
-
{{ end }}
50
19
{{ end }}
51
20
21
+
{{ define "save" }}
22
+
<button
23
+
class="btn-create py-0 flex gap-1 items-center group text-sm"
24
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
25
+
hx-include="#edit-textarea-{{ .Comment.Id }}"
26
+
hx-target="#comment-body-{{ .Comment.Id }}"
27
+
hx-swap="outerHTML">
28
+
{{ i "check" "size-4" }}
29
+
save
30
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
+
</button>
32
+
{{ end }}
33
+
34
+
{{ define "cancel" }}
35
+
<button
36
+
class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group"
37
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
38
+
hx-target="#comment-body-{{ .Comment.Id }}"
39
+
hx-swap="outerHTML">
40
+
{{ i "x" "size-4" }}
41
+
cancel
42
+
</button>
43
+
{{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
···
1
-
{{ define "repo/issues/fragments/issueComment" }}
2
-
{{ with .Comment }}
3
-
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
6
-
7
-
<!-- show user "hats" -->
8
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
9
-
{{ if $isIssueAuthor }}
10
-
<span class="before:content-['·']"></span>
11
-
author
12
-
{{ end }}
13
-
14
-
<span class="before:content-['·']"></span>
15
-
<a
16
-
href="#{{ .CommentId }}"
17
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
18
-
id="{{ .CommentId }}">
19
-
{{ if .Deleted }}
20
-
deleted {{ template "repo/fragments/time" .Deleted }}
21
-
{{ else if .Edited }}
22
-
edited {{ template "repo/fragments/time" .Edited }}
23
-
{{ else }}
24
-
{{ template "repo/fragments/time" .Created }}
25
-
{{ end }}
26
-
</a>
27
-
28
-
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
29
-
{{ if and $isCommentOwner (not .Deleted) }}
30
-
<button
31
-
class="btn px-2 py-1 text-sm"
32
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
33
-
hx-swap="outerHTML"
34
-
hx-target="#comment-container-{{.CommentId}}"
35
-
>
36
-
{{ i "pencil" "w-4 h-4" }}
37
-
</button>
38
-
<button
39
-
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
40
-
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
41
-
hx-confirm="Are you sure you want to delete your comment?"
42
-
hx-swap="outerHTML"
43
-
hx-target="#comment-container-{{.CommentId}}"
44
-
>
45
-
{{ i "trash-2" "w-4 h-4" }}
46
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
47
-
</button>
48
-
{{ end }}
49
-
50
-
</div>
51
-
{{ if not .Deleted }}
52
-
<div class="prose dark:prose-invert">
53
-
{{ .Body | markdown }}
54
-
</div>
55
-
{{ end }}
56
-
</div>
57
-
{{ end }}
58
-
{{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
···
1
+
{{ define "repo/issues/fragments/issueCommentBody" }}
2
+
<div id="comment-body-{{.Comment.Id}}">
3
+
{{ if not .Comment.Deleted }}
4
+
<div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div>
5
+
{{ else }}
6
+
<div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div>
7
+
{{ end }}
8
+
</div>
9
+
{{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
···
1
+
{{ define "repo/issues/fragments/putIssue" }}
2
+
<!-- this form is used for new and edit, .Issue is passed when editing -->
3
+
<form
4
+
{{ if eq .Action "edit" }}
5
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
6
+
{{ else }}
7
+
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
8
+
{{ end }}
9
+
hx-swap="none"
10
+
hx-indicator="#spinner">
11
+
<div class="flex flex-col gap-2">
12
+
<div>
13
+
<label for="title">title</label>
14
+
<input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" />
15
+
</div>
16
+
<div>
17
+
<label for="body">body</label>
18
+
<textarea
19
+
name="body"
20
+
id="body"
21
+
rows="6"
22
+
class="w-full resize-y"
23
+
placeholder="Describe your issue. Markdown is supported."
24
+
>{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
25
+
</div>
26
+
<div class="flex justify-between">
27
+
<div id="issues" class="error"></div>
28
+
<div class="flex gap-2 items-center">
29
+
<a
30
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
31
+
type="button"
32
+
{{ if .Issue }}
33
+
href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}"
34
+
{{ else }}
35
+
href="/{{ .RepoInfo.FullName }}/issues"
36
+
{{ end }}
37
+
>
38
+
{{ i "x" "w-4 h-4" }}
39
+
cancel
40
+
</a>
41
+
<button type="submit" class="btn-create flex items-center gap-2">
42
+
{{ if eq .Action "edit" }}
43
+
{{ i "pencil" "w-4 h-4" }}
44
+
{{ .Action }} issue
45
+
{{ else }}
46
+
{{ i "circle-plus" "w-4 h-4" }}
47
+
{{ .Action }} issue
48
+
{{ end }}
49
+
<span id="spinner" class="group">
50
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
51
+
</span>
52
+
</button>
53
+
</div>
54
+
</div>
55
+
</div>
56
+
</form>
57
+
{{ end }}
+1
-33
appview/pages/templates/repo/issues/new.html
+1
-33
appview/pages/templates/repo/issues/new.html
···
1
1
{{ define "title" }}new issue · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "repoContent" }}
4
-
<form
5
-
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
6
-
class="space-y-6"
7
-
hx-swap="none"
8
-
hx-indicator="#spinner"
9
-
>
10
-
<div class="flex flex-col gap-4">
11
-
<div>
12
-
<label for="title">title</label>
13
-
<input type="text" name="title" id="title" class="w-full" />
14
-
</div>
15
-
<div>
16
-
<label for="body">body</label>
17
-
<textarea
18
-
name="body"
19
-
id="body"
20
-
rows="6"
21
-
class="w-full resize-y"
22
-
placeholder="Describe your issue. Markdown is supported."
23
-
></textarea>
24
-
</div>
25
-
<div>
26
-
<button type="submit" class="btn-create flex items-center gap-2">
27
-
{{ i "circle-plus" "w-4 h-4" }}
28
-
create issue
29
-
<span id="spinner" class="group">
30
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
-
</span>
32
-
</button>
33
-
</div>
34
-
</div>
35
-
<div id="issues" class="error"></div>
36
-
</form>
4
+
{{ template "repo/issues/fragments/putIssue" . }}
37
5
{{ end }}
+5
xrpc/errors/errors.go
+5
xrpc/errors/errors.go
+15
flake.lock
+15
flake.lock
···
1
1
{
2
2
"nodes": {
3
+
"flake-compat": {
4
+
"flake": false,
5
+
"locked": {
6
+
"lastModified": 1751685974,
7
+
"narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=",
8
+
"rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1",
9
+
"type": "tarball",
10
+
"url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1"
11
+
},
12
+
"original": {
13
+
"type": "tarball",
14
+
"url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"
15
+
}
16
+
},
3
17
"flake-utils": {
4
18
"inputs": {
5
19
"systems": "systems"
···
136
150
},
137
151
"root": {
138
152
"inputs": {
153
+
"flake-compat": "flake-compat",
139
154
"gomod2nix": "gomod2nix",
140
155
"htmx-src": "htmx-src",
141
156
"htmx-ws-src": "htmx-ws-src",
+44
contrib/Tiltfile
+44
contrib/Tiltfile
···
1
+
common_env = {
2
+
"TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""),
3
+
"TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""),
4
+
"TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"),
5
+
"TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"),
6
+
}
7
+
8
+
nix_globs = ["nix/**", "flake.nix", "flake.lock"]
9
+
10
+
local_resource(
11
+
name="appview",
12
+
serve_cmd="nix run .#watch-appview",
13
+
serve_dir="..",
14
+
deps=nix_globs,
15
+
env=common_env,
16
+
allow_parallel=True,
17
+
)
18
+
19
+
local_resource(
20
+
name="tailwind",
21
+
serve_cmd="nix run .#watch-tailwind",
22
+
serve_dir="..",
23
+
deps=nix_globs,
24
+
env=common_env,
25
+
allow_parallel=True,
26
+
)
27
+
28
+
local_resource(
29
+
name="redis",
30
+
serve_cmd="redis-server",
31
+
serve_dir="..",
32
+
deps=nix_globs,
33
+
env=common_env,
34
+
allow_parallel=True,
35
+
)
36
+
37
+
local_resource(
38
+
name="vm",
39
+
serve_cmd="nix run --impure .#vm",
40
+
serve_dir="..",
41
+
deps=nix_globs,
42
+
env=common_env,
43
+
allow_parallel=True,
44
+
)
+90
appview/pages/templates/fragments/multiline-select.html
+90
appview/pages/templates/fragments/multiline-select.html
···
1
+
{{ define "fragments/multiline-select" }}
2
+
<script>
3
+
function highlight(scroll = false) {
4
+
document.querySelectorAll(".hl").forEach(el => {
5
+
el.classList.remove("hl");
6
+
});
7
+
8
+
const hash = window.location.hash;
9
+
if (!hash || !hash.startsWith("#L")) {
10
+
return;
11
+
}
12
+
13
+
const rangeStr = hash.substring(2);
14
+
const parts = rangeStr.split("-");
15
+
let startLine, endLine;
16
+
17
+
if (parts.length === 2) {
18
+
startLine = parseInt(parts[0], 10);
19
+
endLine = parseInt(parts[1], 10);
20
+
} else {
21
+
startLine = parseInt(parts[0], 10);
22
+
endLine = startLine;
23
+
}
24
+
25
+
if (isNaN(startLine) || isNaN(endLine)) {
26
+
console.log("nan");
27
+
console.log(startLine);
28
+
console.log(endLine);
29
+
return;
30
+
}
31
+
32
+
let target = null;
33
+
34
+
for (let i = startLine; i<= endLine; i++) {
35
+
const idEl = document.getElementById(`L${i}`);
36
+
if (idEl) {
37
+
const el = idEl.closest(".line");
38
+
if (el) {
39
+
el.classList.add("hl");
40
+
target = el;
41
+
}
42
+
}
43
+
}
44
+
45
+
if (scroll && target) {
46
+
target.scrollIntoView({
47
+
behavior: "smooth",
48
+
block: "center",
49
+
});
50
+
}
51
+
}
52
+
53
+
document.addEventListener("DOMContentLoaded", () => {
54
+
console.log("DOMContentLoaded");
55
+
highlight(true);
56
+
});
57
+
window.addEventListener("hashchange", () => {
58
+
console.log("hashchange");
59
+
highlight();
60
+
});
61
+
window.addEventListener("popstate", () => {
62
+
console.log("popstate");
63
+
highlight();
64
+
});
65
+
66
+
const lineNumbers = document.querySelectorAll('a[href^="#L"');
67
+
let startLine = null;
68
+
69
+
lineNumbers.forEach(el => {
70
+
el.addEventListener("click", (event) => {
71
+
event.preventDefault();
72
+
const currentLine = parseInt(el.href.split("#L")[1]);
73
+
74
+
if (event.shiftKey && startLine !== null) {
75
+
const endLine = currentLine;
76
+
const min = Math.min(startLine, endLine);
77
+
const max = Math.max(startLine, endLine);
78
+
const newHash = `#L${min}-${max}`;
79
+
history.pushState(null, '', newHash);
80
+
} else {
81
+
const newHash = `#L${currentLine}`;
82
+
history.pushState(null, '', newHash);
83
+
startLine = currentLine;
84
+
}
85
+
86
+
highlight();
87
+
});
88
+
});
89
+
</script>
90
+
{{ end }}
+56
appview/pages/templates/fragments/dolly/logo.html
+56
appview/pages/templates/fragments/dolly/logo.html
···
1
+
{{ define "fragments/dolly/logo" }}
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
class="{{.}}"
6
+
width="25"
7
+
height="25"
8
+
viewBox="0 0 25 25"
9
+
sodipodi:docname="tangled_dolly_face_only.png"
10
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
11
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12
+
xmlns:xlink="http://www.w3.org/1999/xlink"
13
+
xmlns="http://www.w3.org/2000/svg"
14
+
xmlns:svg="http://www.w3.org/2000/svg">
15
+
<title>Dolly</title>
16
+
<defs
17
+
id="defs1" />
18
+
<sodipodi:namedview
19
+
id="namedview1"
20
+
pagecolor="#ffffff"
21
+
bordercolor="#000000"
22
+
borderopacity="0.25"
23
+
inkscape:showpageshadow="2"
24
+
inkscape:pageopacity="0.0"
25
+
inkscape:pagecheckerboard="true"
26
+
inkscape:deskcolor="#d5d5d5">
27
+
<inkscape:page
28
+
x="0"
29
+
y="0"
30
+
width="25"
31
+
height="25"
32
+
id="page2"
33
+
margin="0"
34
+
bleed="0" />
35
+
</sodipodi:namedview>
36
+
<g
37
+
inkscape:groupmode="layer"
38
+
inkscape:label="Image"
39
+
id="g1">
40
+
<image
41
+
width="252.48"
42
+
height="248.96001"
43
+
preserveAspectRatio="none"
44
+
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9 kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7 vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0 M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0 AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39 NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz 3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS vkUNCjRNU+qvG2ASmKpvJ+rb9n3T9e0EvTCx3Pfr/o8ttf9M/fVS/bOL9bbU97HlvrcT7d/bNE1/ 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 0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/ //6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj 4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC /wcO9A7eMaXQEQAAAABJRU5ErkJggg== "
45
+
id="image1"
46
+
x="-233.6257"
47
+
y="10.383364"
48
+
style="display:none" />
49
+
<path
50
+
fill="currentColor"
51
+
style="stroke-width:0.111183"
52
+
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"
53
+
id="path4" />
54
+
</g>
55
+
</svg>
56
+
{{ end }}
+57
appview/pages/templates/fragments/dolly/silhouette.html
+57
appview/pages/templates/fragments/dolly/silhouette.html
···
1
+
{{ define "fragments/dolly/silhouette" }}
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
width="32"
6
+
height="32"
7
+
viewBox="0 0 25 25"
8
+
sodipodi:docname="tangled_dolly_silhouette.png"
9
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
+
xmlns="http://www.w3.org/2000/svg"
12
+
xmlns:svg="http://www.w3.org/2000/svg">
13
+
<style>
14
+
.dolly {
15
+
color: #000000;
16
+
}
17
+
18
+
@media (prefers-color-scheme: dark) {
19
+
.dolly {
20
+
color: #ffffff;
21
+
}
22
+
}
23
+
</style>
24
+
<title>Dolly</title>
25
+
<defs
26
+
id="defs1" />
27
+
<sodipodi:namedview
28
+
id="namedview1"
29
+
pagecolor="#ffffff"
30
+
bordercolor="#000000"
31
+
borderopacity="0.25"
32
+
inkscape:showpageshadow="2"
33
+
inkscape:pageopacity="0.0"
34
+
inkscape:pagecheckerboard="true"
35
+
inkscape:deskcolor="#d1d1d1">
36
+
<inkscape:page
37
+
x="0"
38
+
y="0"
39
+
width="25"
40
+
height="25"
41
+
id="page2"
42
+
margin="0"
43
+
bleed="0" />
44
+
</sodipodi:namedview>
45
+
<g
46
+
inkscape:groupmode="layer"
47
+
inkscape:label="Image"
48
+
id="g1">
49
+
<path
50
+
class="dolly"
51
+
fill="currentColor"
52
+
style="stroke-width:1.12248"
53
+
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"
54
+
id="path1" />
55
+
</g>
56
+
</svg>
57
+
{{ end }}
+9
appview/pages/templates/fragments/logotypeSmall.html
+9
appview/pages/templates/fragments/logotypeSmall.html
···
1
+
{{ define "fragments/logotypeSmall" }}
2
+
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
4
+
<span class="font-bold text-xl not-italic">tangled</span>
5
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
+
alpha
7
+
</span>
8
+
<span>
9
+
{{ end }}
+2
-2
appview/pages/templates/repo/pipelines/pipelines.html
+2
-2
appview/pages/templates/repo/pipelines/pipelines.html
···
2
2
3
3
{{ define "extrameta" }}
4
4
{{ $title := "pipelines"}}
5
-
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }}
6
6
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
7
{{ end }}
8
8
···
60
60
<span class="inline-flex gap-2 items-center">
61
61
<span class="font-bold">{{ $target }}</span>
62
62
{{ i "arrow-left" "size-4" }}
63
-
{{ .Trigger.PRSourceBranch }}
63
+
{{ .Trigger.PRSourceBranch }}
64
64
<span class="text-sm font-mono">
65
65
@
66
66
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
+1
-1
appview/pages/templates/repo/pipelines/workflow.html
+1
-1
appview/pages/templates/repo/pipelines/workflow.html
···
2
2
3
3
{{ define "extrameta" }}
4
4
{{ $title := "pipelines"}}
5
-
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }}
6
6
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
7
{{ end }}
8
8
+1
-1
appview/pages/templates/spindles/index.html
+1
-1
appview/pages/templates/spindles/index.html
···
5
5
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
6
6
<span class="flex items-center gap-1">
7
7
{{ i "book" "w-3 h-3" }}
8
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a>
8
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a>
9
9
</span>
10
10
</div>
11
11
+1
-1
appview/pages/templates/strings/dashboard.html
+1
-1
appview/pages/templates/strings/dashboard.html
···
3
3
{{ define "extrameta" }}
4
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
5
<meta property="og:type" content="profile" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
6
+
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" />
7
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
8
{{ end }}
9
9
+1
-1
appview/pages/templates/strings/string.html
+1
-1
appview/pages/templates/strings/string.html
···
4
4
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
5
5
<meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" />
6
6
<meta property="og:type" content="object" />
7
-
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
7
+
<meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
8
8
<meta property="og:description" content="{{ .String.Description }}" />
9
9
{{ end }}
10
10
+1
-2
appview/pages/templates/timeline/fragments/hero.html
+1
-2
appview/pages/templates/timeline/fragments/hero.html
···
22
22
</div>
23
23
24
24
<figure class="w-full hidden md:block md:w-auto">
25
-
<a href="https://tangled.sh/@tangled.sh/core" class="block">
25
+
<a href="https://tangled.org/@tangled.org/core" class="block">
26
26
<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" />
27
27
</a>
28
28
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
···
31
31
</figure>
32
32
</div>
33
33
{{ end }}
34
-
+4
-4
readme.md
+4
-4
readme.md
···
1
1
# tangled
2
2
3
3
Hello Tanglers! This is the codebase for
4
-
[Tangled](https://tangled.sh)—a code collaboration platform built
4
+
[Tangled](https://tangled.org)—a code collaboration platform built
5
5
on the [AT Protocol](https://atproto.com).
6
6
7
-
Read the introduction to Tangled [here](https://blog.tangled.sh/intro). Join the
8
-
[Discord](https://chat.tangled.sh) or IRC at [#tangled on
7
+
Read the introduction to Tangled [here](https://blog.tangled.org/intro). Join the
8
+
[Discord](https://chat.tangled.org) or IRC at [#tangled on
9
9
libera.chat](https://web.libera.chat/#tangled).
10
10
11
11
## docs
···
17
17
## security
18
18
19
19
If you've identified a security issue in Tangled, please email
20
-
[security@tangled.sh](mailto:security@tangled.sh) with details!
20
+
[security@tangled.org](mailto:security@tangled.org) with details!
+1
-1
appview/cache/session/store.go
+1
-1
appview/cache/session/store.go
+1
-1
appview/pipelines/router.go
+1
-1
appview/pipelines/router.go
+4
-4
appview/serververify/verify.go
+4
-4
appview/serververify/verify.go
···
6
6
"fmt"
7
7
8
8
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/appview/db"
11
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
12
-
"tangled.sh/tangled.sh/core/rbac"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/db"
11
+
"tangled.org/core/appview/xrpcclient"
12
+
"tangled.org/core/rbac"
13
13
)
14
14
15
15
var (
+1
-1
cmd/combinediff/main.go
+1
-1
cmd/combinediff/main.go
+1
-1
cmd/interdiff/main.go
+1
-1
cmd/interdiff/main.go
+2
-2
docs/knot-hosting.md
+2
-2
docs/knot-hosting.md
···
19
19
First, clone this repository:
20
20
21
21
```
22
-
git clone https://tangled.sh/@tangled.sh/core
22
+
git clone https://tangled.org/@tangled.org/core
23
23
```
24
24
25
25
Then, build the `knot` CLI. This is the knot administration and operation tool.
···
130
130
131
131
You should now have a running knot server! You can finalize
132
132
your registration by hitting the `verify` button on the
133
-
[/knots](https://tangled.sh/knots) page. This simply creates
133
+
[/knots](https://tangled.org/knots) page. This simply creates
134
134
a record on your PDS to announce the existence of the knot.
135
135
136
136
### custom paths
+4
-5
docs/migrations.md
+4
-5
docs/migrations.md
···
14
14
For knots:
15
15
16
16
- Upgrade to latest tag (v1.9.0 or above)
17
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
17
+
- Head to the [knot dashboard](https://tangled.org/knots) and
18
18
hit the "retry" button to verify your knot
19
19
20
20
For spindles:
21
21
22
22
- Upgrade to latest tag (v1.9.0 or above)
23
23
- Head to the [spindle
24
-
dashboard](https://tangled.sh/spindles) and hit the
24
+
dashboard](https://tangled.org/spindles) and hit the
25
25
"retry" button to verify your spindle
26
26
27
27
## Upgrading from v1.7.x
···
38
38
environment variable entirely
39
39
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
40
your DID. You can find your DID in the
41
-
[settings](https://tangled.sh/settings) page.
41
+
[settings](https://tangled.org/settings) page.
42
42
- Restart your knot once you have replaced the environment
43
43
variable
44
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
44
+
- Head to the [knot dashboard](https://tangled.org/knots) and
45
45
hit the "retry" button to verify your knot. This simply
46
46
writes a `sh.tangled.knot` record to your PDS.
47
47
···
57
57
};
58
58
};
59
59
```
60
-
+1
-1
keyfetch/keyfetch.go
+1
-1
keyfetch/keyfetch.go
+1
-1
knotserver/db/events.go
+1
-1
knotserver/db/events.go
+2
-2
knotserver/git/diff.go
+2
-2
knotserver/git/diff.go
···
12
12
"github.com/bluekeyes/go-gitdiff/gitdiff"
13
13
"github.com/go-git/go-git/v5/plumbing"
14
14
"github.com/go-git/go-git/v5/plumbing/object"
15
-
"tangled.sh/tangled.sh/core/patchutil"
16
-
"tangled.sh/tangled.sh/core/types"
15
+
"tangled.org/core/patchutil"
16
+
"tangled.org/core/types"
17
17
)
18
18
19
19
func (g *GitRepo) Diff() (*types.NiceDiff, error) {
+1
-1
knotserver/git/post_receive.go
+1
-1
knotserver/git/post_receive.go
+1
-1
knotserver/git/tree.go
+1
-1
knotserver/git/tree.go
+3
-3
knotserver/xrpc/delete_repo.go
+3
-3
knotserver/xrpc/delete_repo.go
···
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
12
"github.com/bluesky-social/indigo/xrpc"
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/rbac"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/rbac"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+2
-2
knotserver/xrpc/list_keys.go
+2
-2
knotserver/xrpc/list_keys.go
+2
-2
knotserver/xrpc/owner.go
+2
-2
knotserver/xrpc/owner.go
+3
-3
knotserver/xrpc/repo_branch.go
+3
-3
knotserver/xrpc/repo_branch.go
···
5
5
"net/url"
6
6
"time"
7
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/knotserver/git"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/knotserver/git"
10
+
xrpcerr "tangled.org/core/xrpc/errors"
11
11
)
12
12
13
13
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_compare.go
+3
-3
knotserver/xrpc/repo_compare.go
···
4
4
"fmt"
5
5
"net/http"
6
6
7
-
"tangled.sh/tangled.sh/core/knotserver/git"
8
-
"tangled.sh/tangled.sh/core/types"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
10
)
11
11
12
12
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_diff.go
+3
-3
knotserver/xrpc/repo_diff.go
···
3
3
import (
4
4
"net/http"
5
5
6
-
"tangled.sh/tangled.sh/core/knotserver/git"
7
-
"tangled.sh/tangled.sh/core/types"
8
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
6
+
"tangled.org/core/knotserver/git"
7
+
"tangled.org/core/types"
8
+
xrpcerr "tangled.org/core/xrpc/errors"
9
9
)
10
10
11
11
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_languages.go
+3
-3
knotserver/xrpc/repo_languages.go
···
6
6
"net/http"
7
7
"time"
8
8
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/knotserver/git"
11
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/knotserver/git"
11
+
xrpcerr "tangled.org/core/xrpc/errors"
12
12
)
13
13
14
14
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+2
-2
knotserver/xrpc/version.go
+2
-2
knotserver/xrpc/version.go
···
5
5
"net/http"
6
6
"runtime/debug"
7
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
8
+
"tangled.org/core/api/tangled"
9
9
)
10
10
11
11
// version is set during build time.
···
24
24
var modified bool
25
25
26
26
for _, mod := range info.Deps {
27
-
if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
27
+
if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" {
28
28
modVer = mod.Version
29
29
break
30
30
}
+1
-1
lexicon-build-config.json
+1
-1
lexicon-build-config.json
+1
-1
patchutil/patchutil.go
+1
-1
patchutil/patchutil.go
+4
-4
spindle/db/events.go
+4
-4
spindle/db/events.go
···
5
5
"fmt"
6
6
"time"
7
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/notifier"
10
-
"tangled.sh/tangled.sh/core/spindle/models"
11
-
"tangled.sh/tangled.sh/core/tid"
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/notifier"
10
+
"tangled.org/core/spindle/models"
11
+
"tangled.org/core/tid"
12
12
)
13
13
14
14
type Event struct {
+5
-5
spindle/engine/engine.go
+5
-5
spindle/engine/engine.go
···
8
8
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
10
"golang.org/x/sync/errgroup"
11
-
"tangled.sh/tangled.sh/core/notifier"
12
-
"tangled.sh/tangled.sh/core/spindle/config"
13
-
"tangled.sh/tangled.sh/core/spindle/db"
14
-
"tangled.sh/tangled.sh/core/spindle/models"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
11
+
"tangled.org/core/notifier"
12
+
"tangled.org/core/spindle/config"
13
+
"tangled.org/core/spindle/db"
14
+
"tangled.org/core/spindle/models"
15
+
"tangled.org/core/spindle/secrets"
16
16
)
17
17
18
18
var (
+2
-2
spindle/engines/nixery/setup_steps.go
+2
-2
spindle/engines/nixery/setup_steps.go
+9
-9
spindle/xrpc/xrpc.go
+9
-9
spindle/xrpc/xrpc.go
···
8
8
9
9
"github.com/go-chi/chi/v5"
10
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/config"
15
-
"tangled.sh/tangled.sh/core/spindle/db"
16
-
"tangled.sh/tangled.sh/core/spindle/models"
17
-
"tangled.sh/tangled.sh/core/spindle/secrets"
18
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/config"
15
+
"tangled.org/core/spindle/db"
16
+
"tangled.org/core/spindle/models"
17
+
"tangled.org/core/spindle/secrets"
18
+
xrpcerr "tangled.org/core/xrpc/errors"
19
+
"tangled.org/core/xrpc/serviceauth"
20
20
)
21
21
22
22
const ActorDid string = "ActorDid"
+1
-1
workflow/def.go
+1
-1
workflow/def.go
+42
api/tangled/labeldefinition.go
+42
api/tangled/labeldefinition.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.label.definition
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
LabelDefinitionNSID = "sh.tangled.label.definition"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.label.definition", &LabelDefinition{})
17
+
} //
18
+
// RECORDTYPE: LabelDefinition
19
+
type LabelDefinition struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.label.definition" cborgen:"$type,const=sh.tangled.label.definition"`
21
+
// color: The hex value for the background color for the label. Appviews may choose to respect this.
22
+
Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
23
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
+
// multiple: Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]
25
+
Multiple *bool `json:"multiple,omitempty" cborgen:"multiple,omitempty"`
26
+
// name: The display name of this label.
27
+
Name string `json:"name" cborgen:"name"`
28
+
// scope: The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.
29
+
Scope []string `json:"scope" cborgen:"scope"`
30
+
// valueType: The type definition of this label. Appviews may allow sorting for certain types.
31
+
ValueType *LabelDefinition_ValueType `json:"valueType" cborgen:"valueType"`
32
+
}
33
+
34
+
// LabelDefinition_ValueType is a "valueType" in the sh.tangled.label.definition schema.
35
+
type LabelDefinition_ValueType struct {
36
+
// enum: Closed set of values that this label can take.
37
+
Enum []string `json:"enum,omitempty" cborgen:"enum,omitempty"`
38
+
// format: An optional constraint that can be applied on string concrete types.
39
+
Format string `json:"format" cborgen:"format"`
40
+
// type: The concrete type of this label's value.
41
+
Type string `json:"type" cborgen:"type"`
42
+
}
+89
lexicons/label/definition.json
+89
lexicons/label/definition.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.label.definition",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"valueType",
15
+
"scope",
16
+
"createdAt"
17
+
],
18
+
"properties": {
19
+
"name": {
20
+
"type": "string",
21
+
"description": "The display name of this label.",
22
+
"minGraphemes": 1,
23
+
"maxGraphemes": 40
24
+
},
25
+
"valueType": {
26
+
"type": "ref",
27
+
"ref": "#valueType",
28
+
"description": "The type definition of this label. Appviews may allow sorting for certain types."
29
+
},
30
+
"scope": {
31
+
"type": "array",
32
+
"description": "The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.",
33
+
"items": {
34
+
"type": "string",
35
+
"format": "nsid"
36
+
}
37
+
},
38
+
"color": {
39
+
"type": "string",
40
+
"description": "The hex value for the background color for the label. Appviews may choose to respect this."
41
+
},
42
+
"createdAt": {
43
+
"type": "string",
44
+
"format": "datetime"
45
+
},
46
+
"multiple": {
47
+
"type": "boolean",
48
+
"description": "Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]"
49
+
}
50
+
}
51
+
}
52
+
},
53
+
"valueType": {
54
+
"type": "object",
55
+
"required": [
56
+
"type",
57
+
"format"
58
+
],
59
+
"properties": {
60
+
"type": {
61
+
"type": "string",
62
+
"enum": [
63
+
"null",
64
+
"boolean",
65
+
"integer",
66
+
"string"
67
+
],
68
+
"description": "The concrete type of this label's value."
69
+
},
70
+
"format": {
71
+
"type": "string",
72
+
"enum": [
73
+
"any",
74
+
"did",
75
+
"nsid"
76
+
],
77
+
"description": "An optional constraint that can be applied on string concrete types."
78
+
},
79
+
"enum": {
80
+
"type": "array",
81
+
"description": "Closed set of values that this label can take.",
82
+
"items": {
83
+
"type": "string"
84
+
}
85
+
}
86
+
}
87
+
}
88
+
}
89
+
}
+34
api/tangled/labelop.go
+34
api/tangled/labelop.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.label.op
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
LabelOpNSID = "sh.tangled.label.op"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.label.op", &LabelOp{})
17
+
} //
18
+
// RECORDTYPE: LabelOp
19
+
type LabelOp struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.label.op" cborgen:"$type,const=sh.tangled.label.op"`
21
+
Add []*LabelOp_Operand `json:"add" cborgen:"add"`
22
+
Delete []*LabelOp_Operand `json:"delete" cborgen:"delete"`
23
+
PerformedAt string `json:"performedAt" cborgen:"performedAt"`
24
+
// subject: The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op.
25
+
Subject string `json:"subject" cborgen:"subject"`
26
+
}
27
+
28
+
// LabelOp_Operand is a "operand" in the sh.tangled.label.op schema.
29
+
type LabelOp_Operand struct {
30
+
// key: ATURI to the label definition
31
+
Key string `json:"key" cborgen:"key"`
32
+
// value: Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value.
33
+
Value string `json:"value" cborgen:"value"`
34
+
}
+2
cmd/gen.go
+2
cmd/gen.go
+64
lexicons/label/op.json
+64
lexicons/label/op.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.label.op",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"add",
15
+
"delete",
16
+
"performedAt"
17
+
],
18
+
"properties": {
19
+
"subject": {
20
+
"type": "string",
21
+
"format": "at-uri",
22
+
"description": "The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op."
23
+
},
24
+
"performedAt": {
25
+
"type": "string",
26
+
"format": "datetime"
27
+
},
28
+
"add": {
29
+
"type": "array",
30
+
"items": {
31
+
"type": "ref",
32
+
"ref": "#operand"
33
+
}
34
+
},
35
+
"delete": {
36
+
"type": "array",
37
+
"items": {
38
+
"type": "ref",
39
+
"ref": "#operand"
40
+
}
41
+
}
42
+
}
43
+
}
44
+
},
45
+
"operand": {
46
+
"type": "object",
47
+
"required": [
48
+
"key",
49
+
"value"
50
+
],
51
+
"properties": {
52
+
"key": {
53
+
"type": "string",
54
+
"format": "at-uri",
55
+
"description": "ATURI to the label definition"
56
+
},
57
+
"value": {
58
+
"type": "string",
59
+
"description": "Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value."
60
+
}
61
+
}
62
+
}
63
+
}
64
+
}
-36
knotserver/util.go
-36
knotserver/util.go
···
1
1
package knotserver
2
2
3
3
import (
4
-
"net/http"
5
-
"os"
6
-
"path/filepath"
7
-
8
4
"github.com/bluesky-social/indigo/atproto/syntax"
9
-
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"github.com/go-chi/chi/v5"
11
5
)
12
6
13
-
func didPath(r *http.Request) string {
14
-
did := chi.URLParam(r, "did")
15
-
name := chi.URLParam(r, "name")
16
-
path, _ := securejoin.SecureJoin(did, name)
17
-
filepath.Clean(path)
18
-
return path
19
-
}
20
-
21
-
func getDescription(path string) (desc string) {
22
-
db, err := os.ReadFile(filepath.Join(path, "description"))
23
-
if err == nil {
24
-
desc = string(db)
25
-
} else {
26
-
desc = ""
27
-
}
28
-
return
29
-
}
30
-
func setContentDisposition(w http.ResponseWriter, name string) {
31
-
h := "inline; filename=\"" + name + "\""
32
-
w.Header().Add("Content-Disposition", h)
33
-
}
34
-
35
-
func setGZipMIME(w http.ResponseWriter) {
36
-
setMIME(w, "application/gzip")
37
-
}
38
-
39
-
func setMIME(w http.ResponseWriter, mime string) {
40
-
w.Header().Add("Content-Type", mime)
41
-
}
42
-
43
7
var TIDClock = syntax.NewTIDClock(0)
44
8
45
9
func TID() string {
+8
-5
lexicons/repo/repo.json
+8
-5
lexicons/repo/repo.json
···
12
12
"required": [
13
13
"name",
14
14
"knot",
15
-
"owner",
16
15
"createdAt"
17
16
],
18
17
"properties": {
···
20
19
"type": "string",
21
20
"description": "name of the repo"
22
21
},
23
-
"owner": {
24
-
"type": "string",
25
-
"format": "did"
26
-
},
27
22
"knot": {
28
23
"type": "string",
29
24
"description": "knot where the repo was created"
···
42
37
"format": "uri",
43
38
"description": "source of the repo"
44
39
},
40
+
"labels": {
41
+
"type": "array",
42
+
"description": "List of labels that this repo subscribes to",
43
+
"items": {
44
+
"type": "string",
45
+
"format": "at-uri"
46
+
}
47
+
},
45
48
"createdAt": {
46
49
"type": "string",
47
50
"format": "datetime"
+1
-1
spindle/xrpc/add_secret.go
+1
-1
spindle/xrpc/add_secret.go
+1
-1
spindle/xrpc/list_secrets.go
+1
-1
spindle/xrpc/list_secrets.go
+1
-1
spindle/xrpc/remove_secret.go
+1
-1
spindle/xrpc/remove_secret.go
+6
appview/pages/templates/repo/fragments/colorBall.html
+6
appview/pages/templates/repo/fragments/colorBall.html
+1
-1
appview/pages/templates/repo/index.html
+1
-1
appview/pages/templates/repo/index.html
···
49
49
<div
50
50
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
51
51
>
52
-
{{ template "repo/fragments/languageBall" $value.Name }}
52
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }}
53
53
<div>{{ or $value.Name "Other" }}
54
54
<span class="text-gray-500 dark:text-gray-400">
55
55
{{ if lt $value.Percentage 0.05 }}
+1
-1
appview/pages/templates/user/overview.html
+1
-1
appview/pages/templates/user/overview.html
···
73
73
{{ with .Repo.RepoStats }}
74
74
{{ with .Language }}
75
75
<div class="flex gap-2 items-center text-xs font-mono text-gray-400 ">
76
-
{{ template "repo/fragments/languageBall" . }}
76
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
77
77
<span>{{ . }}</span>
78
78
</div>
79
79
{{end }}
+1
-1
appview/pages/templates/repo/settings/pipelines.html
+1
-1
appview/pages/templates/repo/settings/pipelines.html
···
109
109
hx-swap="none"
110
110
class="flex flex-col gap-2"
111
111
>
112
-
<p class="uppercase p-0">ADD SECRET</p>
112
+
<p class="uppercase p-0 font-bold">ADD SECRET</p>
113
113
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
114
114
<input
115
115
type="text"
+6
appview/pages/templates/labels/fragments/labelDef.html
+6
appview/pages/templates/labels/fragments/labelDef.html
+6
-8
appview/pages/templates/layouts/repobase.html
+6
-8
appview/pages/templates/layouts/repobase.html
···
41
41
{{ template "repo/fragments/repoDescription" . }}
42
42
</section>
43
43
44
-
<section
45
-
class="w-full flex flex-col"
46
-
>
44
+
<section class="w-full flex flex-col" >
47
45
<nav class="w-full pl-4 overflow-auto">
48
46
<div class="flex z-60">
49
47
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
···
80
78
{{ end }}
81
79
</div>
82
80
</nav>
83
-
<section
84
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"
85
-
>
81
+
{{ block "repoContentLayout" . }}
82
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
86
83
{{ block "repoContent" . }}{{ end }}
87
-
</section>
88
-
{{ block "repoAfter" . }}{{ end }}
84
+
</section>
85
+
{{ block "repoAfter" . }}{{ end }}
86
+
{{ end }}
89
87
</section>
90
88
{{ end }}
+4
-4
appview/pages/templates/repo/issues/fragments/commentList.html
+4
-4
appview/pages/templates/repo/issues/fragments/commentList.html
···
3
3
{{ range $item := .CommentList }}
4
4
{{ template "commentListing" (list $ .) }}
5
5
{{ end }}
6
-
<div>
6
+
</div>
7
7
{{ end }}
8
8
9
9
{{ define "commentListing" }}
···
16
16
"Issue" $root.Issue
17
17
"Comment" $comment.Self) }}
18
18
19
-
<div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm">
19
+
<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">
20
20
{{ template "topLevelComment" $params }}
21
21
22
-
<div class="relative ml-4 border-l border-gray-300 dark:border-gray-700">
22
+
<div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700">
23
23
{{ range $index, $reply := $comment.Replies }}
24
24
<div class="relative ">
25
25
<!-- Horizontal connector -->
26
-
<div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div>
26
+
<div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div>
27
27
28
28
<div class="pl-2">
29
29
{{
-127
appview/pages/templates/repo/fragments/addLabelModal.html
-127
appview/pages/templates/repo/fragments/addLabelModal.html
···
1
-
{{ define "repo/fragments/addLabelModal" }}
2
-
{{ $root := .root }}
3
-
{{ $subject := .subject }}
4
-
{{ $state := .state }}
5
-
{{ with $root }}
6
-
<form
7
-
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
8
-
hx-on::after-request="this.reset()"
9
-
hx-indicator="#spinner"
10
-
hx-swap="none"
11
-
class="flex flex-col gap-4"
12
-
>
13
-
<p class="text-gray-500 dark:text-gray-400">Add, remove or update labels.</p>
14
-
15
-
<input class="hidden" name="repo" value="{{ .RepoInfo.RepoAt.String }}">
16
-
<input class="hidden" name="subject" value="{{ $subject }}">
17
-
18
-
<div class="flex flex-col gap-2">
19
-
{{ $id := 0 }}
20
-
{{ range $k, $valset := $state.Inner }}
21
-
{{ $d := index $root.LabelDefs $k }}
22
-
{{ range $v, $s := $valset }}
23
-
{{ template "labelCheckbox" (dict "def" $d "key" $k "val" $v "id" $id "isChecked" true) }}
24
-
{{ $id = add $id 1 }}
25
-
{{ end }}
26
-
{{ end }}
27
-
28
-
{{ range $k, $d := $root.LabelDefs }}
29
-
{{ if not ($state.ContainsLabel $k) }}
30
-
{{ template "labelCheckbox" (dict "def" $d "key" $k "val" "" "id" $id "isChecked" false) }}
31
-
{{ $id = add $id 1 }}
32
-
{{ end }}
33
-
{{ else }}
34
-
<span>
35
-
No labels defined yet. You can define custom labels in <a class="underline" href="/{{ .RepoInfo.FullName }}/settings">settings</a>.
36
-
</span>
37
-
{{ end }}
38
-
</div>
39
-
40
-
<div class="flex gap-2 pt-2">
41
-
<button
42
-
type="button"
43
-
popovertarget="add-label-modal"
44
-
popovertargetaction="hide"
45
-
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"
46
-
>
47
-
{{ i "x" "size-4" }} cancel
48
-
</button>
49
-
<button type="submit" class="btn w-1/2 flex items-center">
50
-
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
51
-
<span id="spinner" class="group">
52
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
53
-
</span>
54
-
</button>
55
-
</div>
56
-
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
57
-
</form>
58
-
{{ end }}
59
-
{{ end }}
60
-
61
-
{{ define "labelCheckbox" }}
62
-
{{ $key := .key }}
63
-
{{ $val := .val }}
64
-
{{ $def := .def }}
65
-
{{ $id := .id }}
66
-
{{ $isChecked := .isChecked }}
67
-
<div class="grid grid-cols-[auto_1fr_50%] gap-2 items-center cursor-pointer">
68
-
<input type="checkbox" id="op-{{$id}}" name="op-{{$id}}" value="add" {{if $isChecked}}checked{{end}} class="peer">
69
-
<label for="op-{{$id}}" class="flex items-center gap-2 text-base">{{ template "labels/fragments/labelDef" $def }}</label>
70
-
<div class="w-full hidden peer-checked:block">{{ template "valueTypeInput" (dict "valueType" $def.ValueType "value" $val "key" $key) }}</div>
71
-
<input type="hidden" name="operand-key" value="{{ $key }}">
72
-
</div>
73
-
{{ end }}
74
-
75
-
{{ define "valueTypeInput" }}
76
-
{{ $valueType := .valueType }}
77
-
{{ $value := .value }}
78
-
{{ $key := .key }}
79
-
80
-
{{ if $valueType.IsEnumType }}
81
-
{{ template "enumTypeInput" $ }}
82
-
{{ else if $valueType.IsBool }}
83
-
{{ template "boolTypeInput" $ }}
84
-
{{ else if $valueType.IsInt }}
85
-
{{ template "intTypeInput" $ }}
86
-
{{ else if $valueType.IsString }}
87
-
{{ template "stringTypeInput" $ }}
88
-
{{ else if $valueType.IsNull }}
89
-
{{ template "nullTypeInput" $ }}
90
-
{{ end }}
91
-
{{ end }}
92
-
93
-
{{ define "enumTypeInput" }}
94
-
{{ $valueType := .valueType }}
95
-
{{ $value := .value }}
96
-
<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">
97
-
{{ range $valueType.Enum }}
98
-
<option value="{{.}}" {{ if eq $value . }} selected {{ end }}>{{.}}</option>
99
-
{{ end }}
100
-
</select>
101
-
{{ end }}
102
-
103
-
{{ define "boolTypeInput" }}
104
-
{{ $value := .value }}
105
-
<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">
106
-
<option value="true" {{ if $value }} selected {{ end }}>true</option>
107
-
<option value="false" {{ if not $value }} selected {{ end }}>false</option>
108
-
</select>
109
-
{{ end }}
110
-
111
-
{{ define "intTypeInput" }}
112
-
{{ $value := .value }}
113
-
<input class="p-1 w-full" type="number" name="operand-val" value="{{$value}}" max="100">
114
-
{{ end }}
115
-
116
-
{{ define "stringTypeInput" }}
117
-
{{ $valueType := .valueType }}
118
-
{{ $value := .value }}
119
-
{{ if $valueType.IsDidFormat }}
120
-
{{ $value = resolve .value }}
121
-
{{ end }}
122
-
<input class="p-1 w-full" type="text" name="operand-val" value="{{$value}}">
123
-
{{ end }}
124
-
125
-
{{ define "nullTypeInput" }}
126
-
<input class="p-1" type="hidden" name="operand-val" value="null">
127
-
{{ end }}
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
···
1
+
{{ define "repo/fragments/editLabelPanel" }}
2
+
<form
3
+
id="edit-label-panel"
4
+
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
5
+
hx-indicator="#spinner"
6
+
hx-disabled-elt="#save-btn,#cancel-btn"
7
+
hx-swap="none"
8
+
class="flex flex-col gap-6"
9
+
>
10
+
<input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}">
11
+
<input type="hidden" name="subject" value="{{ .Subject }}">
12
+
{{ template "editBasicLabels" . }}
13
+
{{ template "editKvLabels" . }}
14
+
{{ template "editLabelPanelActions" . }}
15
+
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
16
+
</form>
17
+
{{ end }}
18
+
19
+
{{ define "editBasicLabels" }}
20
+
{{ $defs := .Defs }}
21
+
{{ $subject := .Subject }}
22
+
{{ $state := .State }}
23
+
{{ $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" }}
24
+
<div>
25
+
{{ template "repo/fragments/labelSectionHeaderText" "Labels" }}
26
+
27
+
<div class="flex gap-1 items-center flex-wrap">
28
+
{{ range $k, $d := $defs }}
29
+
{{ $isChecked := $state.ContainsLabel $k }}
30
+
{{ if $d.ValueType.IsNull }}
31
+
{{ $fieldName := $d.AtUri }}
32
+
<label class="{{$labelStyle}}">
33
+
<input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}>
34
+
{{ template "labels/fragments/labelDef" $d }}
35
+
</label>
36
+
{{ end }}
37
+
{{ else }}
38
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">
39
+
No labels defined yet. You can choose default labels or define custom
40
+
labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>.
41
+
</p>
42
+
{{ end }}
43
+
</div>
44
+
</div>
45
+
{{ end }}
46
+
47
+
{{ define "editKvLabels" }}
48
+
{{ $defs := .Defs }}
49
+
{{ $subject := .Subject }}
50
+
{{ $state := .State }}
51
+
{{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-0" }}
52
+
53
+
{{ range $k, $d := $defs }}
54
+
{{ if (not $d.ValueType.IsNull) }}
55
+
{{ $fieldName := $d.AtUri }}
56
+
{{ $valset := $state.GetValSet $k }}
57
+
<div id="label-{{$d.Id}}" class="flex flex-col gap-1">
58
+
{{ template "repo/fragments/labelSectionHeaderText" $d.Name }}
59
+
{{ if (and $d.Multiple $d.ValueType.IsEnum) }}
60
+
<!-- checkbox -->
61
+
{{ range $variant := $d.ValueType.Enum }}
62
+
<label class="{{$labelStyle}}">
63
+
<input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
64
+
{{ $variant }}
65
+
</label>
66
+
{{ end }}
67
+
{{ else if $d.Multiple }}
68
+
<!-- dynamically growing input fields -->
69
+
{{ range $v, $s := $valset }}
70
+
{{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }}
71
+
{{ else }}
72
+
{{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }}
73
+
{{ end }}
74
+
{{ template "addFieldButton" $d }}
75
+
{{ else if $d.ValueType.IsEnum }}
76
+
<!-- radio buttons -->
77
+
{{ $isUsed := $state.ContainsLabel $k }}
78
+
{{ range $variant := $d.ValueType.Enum }}
79
+
<label class="{{$labelStyle}}">
80
+
<input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
81
+
{{ $variant }}
82
+
</label>
83
+
{{ end }}
84
+
<label class="{{$labelStyle}}">
85
+
<input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}>
86
+
None
87
+
</label>
88
+
{{ else }}
89
+
<!-- single input field based on value type -->
90
+
{{ range $v, $s := $valset }}
91
+
{{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }}
92
+
{{ else }}
93
+
{{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }}
94
+
{{ end }}
95
+
{{ end }}
96
+
</div>
97
+
{{ end }}
98
+
{{ end }}
99
+
{{ end }}
100
+
101
+
{{ define "multipleInputField" }}
102
+
<div class="flex gap-1 items-stretch">
103
+
{{ template "valueTypeInput" . }}
104
+
{{ template "removeFieldButton" }}
105
+
</div>
106
+
{{ end }}
107
+
108
+
{{ define "addFieldButton" }}
109
+
<div style="display:none" id="tpl-{{ .Id }}">
110
+
{{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }}
111
+
</div>
112
+
<button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2">
113
+
{{ i "plus" "size-4" }} add
114
+
</button>
115
+
{{ end }}
116
+
117
+
{{ define "removeFieldButton" }}
118
+
<button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500">
119
+
{{ i "trash-2" "size-4" }}
120
+
</button>
121
+
{{ end }}
122
+
123
+
{{ define "valueTypeInput" }}
124
+
{{ $def := .def }}
125
+
{{ $valueType := $def.ValueType }}
126
+
{{ $value := .value }}
127
+
{{ $key := .key }}
128
+
129
+
{{ if $valueType.IsBool }}
130
+
{{ template "boolTypeInput" $ }}
131
+
{{ else if $valueType.IsInt }}
132
+
{{ template "intTypeInput" $ }}
133
+
{{ else if $valueType.IsString }}
134
+
{{ template "stringTypeInput" $ }}
135
+
{{ else if $valueType.IsNull }}
136
+
{{ template "nullTypeInput" $ }}
137
+
{{ end }}
138
+
{{ end }}
139
+
140
+
{{ define "boolTypeInput" }}
141
+
{{ $def := .def }}
142
+
{{ $fieldName := $def.AtUri }}
143
+
{{ $value := .value }}
144
+
{{ $labelStyle = "font-normal normal-case flex items-center gap-2" }}
145
+
<div class="flex flex-col gap-1">
146
+
<label class="{{$labelStyle}}">
147
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
148
+
None
149
+
</label>
150
+
<label class="{{$labelStyle}}">
151
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
152
+
None
153
+
</label>
154
+
<label class="{{$labelStyle}}">
155
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
156
+
None
157
+
</label>
158
+
</div>
159
+
{{ end }}
160
+
161
+
{{ define "intTypeInput" }}
162
+
{{ $def := .def }}
163
+
{{ $fieldName := $def.AtUri }}
164
+
{{ $value := .value }}
165
+
<input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}">
166
+
{{ end }}
167
+
168
+
{{ define "stringTypeInput" }}
169
+
{{ $def := .def }}
170
+
{{ $fieldName := $def.AtUri }}
171
+
{{ $valueType := $def.ValueType }}
172
+
{{ $value := .value }}
173
+
{{ if $valueType.IsDidFormat }}
174
+
{{ $value = trimPrefix (resolve .value) "@" }}
175
+
{{ end }}
176
+
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
177
+
{{ end }}
178
+
179
+
{{ define "nullTypeInput" }}
180
+
{{ $def := .def }}
181
+
{{ $fieldName := $def.AtUri }}
182
+
<input class="p-1" type="hidden" name="{{$fieldName}}" value="null">
183
+
{{ end }}
184
+
185
+
{{ define "editLabelPanelActions" }}
186
+
<div class="flex gap-2 pt-2">
187
+
<button
188
+
id="cancel-btn"
189
+
type="button"
190
+
hx-get="/{{ .RepoInfo.FullName }}/label"
191
+
hx-vals='{"subject": "{{.Subject}}"}'
192
+
hx-swap="outerHTML"
193
+
hx-target="#edit-label-panel"
194
+
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">
195
+
{{ i "x" "size-4" }} cancel
196
+
</button>
197
+
198
+
<button
199
+
id="save-btn"
200
+
type="submit"
201
+
class="btn w-1/2 flex items-center">
202
+
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
203
+
<span id="spinner" class="group">
204
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
205
+
</span>
206
+
</button>
207
+
</div>
208
+
{{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
···
1
+
{{ define "repo/fragments/labelSectionHeader" }}
2
+
3
+
<div class="flex justify-between items-center gap-2">
4
+
{{ template "repo/fragments/labelSectionHeaderText" .Name }}
5
+
{{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }}
6
+
<a
7
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
8
+
hx-get="/{{ .RepoInfo.FullName }}/label/edit"
9
+
hx-vals='{"subject": "{{.Subject}}"}'
10
+
hx-swap="outerHTML"
11
+
hx-target="#label-panel">
12
+
{{ i "pencil" "size-3" }}
13
+
</a>
14
+
{{ end }}
15
+
</div>
16
+
{{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
+138
-86
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
+138
-86
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
···
1
1
{{ define "repo/settings/fragments/addLabelDefModal" }}
2
-
<form
3
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
4
-
hx-indicator="#spinner"
5
-
hx-swap="none"
6
-
hx-on::after-request="if(event.detail.successful) this.reset()"
7
-
class="flex flex-col gap-4"
8
-
>
9
-
<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>
2
+
<div class="grid grid-cols-2">
3
+
<input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked>
4
+
<input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv">
10
5
11
-
<div class="w-full">
12
-
<label for="name">Name</label>
13
-
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
6
+
<!-- Labels as direct siblings -->
7
+
{{ $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" }}
8
+
<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">
9
+
Basic Labels
10
+
</label>
11
+
<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">
12
+
Key-value Labels
13
+
</label>
14
+
15
+
<!-- Basic Labels Content - direct sibling -->
16
+
<div class="mt-4 hidden peer-checked/basic:block col-span-full">
17
+
{{ template "basicLabelDef" . }}
14
18
</div>
15
19
16
-
<!-- Value Type -->
17
-
<div class="w-full">
18
-
<label for="valueType">Value Type</label>
19
-
<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">
20
-
<option value="null" selected>None</option>
21
-
<option value="string">String</option>
22
-
<option value="integer">Integer</option>
23
-
<option value="boolean">Boolean</option>
24
-
</select>
25
-
<details id="constrain-values" class="group hidden">
26
-
<summary class="list-none cursor-pointer flex items-center gap-2 py-2">
27
-
<span class="group-open:hidden inline text-gray-500 dark:text-gray-400">{{ i "square-plus" "w-4 h-4" }}</span>
28
-
<span class="hidden group-open:inline text-gray-500 dark:text-gray-400">{{ i "square-minus" "w-4 h-4" }}</span>
29
-
<span>Constrain values</span>
30
-
</summary>
31
-
<label for="enumValues">Permitted values</label>
32
-
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
33
-
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Enter comma-separated list of permitted values.</p>
34
-
35
-
<label for="valueFormat">String format</label>
36
-
<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">
37
-
<option value="any" selected>Any</option>
38
-
<option value="did">DID</option>
39
-
</select>
40
-
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Choose a string format.</p>
41
-
</details>
20
+
<!-- Key-value Labels Content - direct sibling -->
21
+
<div class="mt-4 hidden peer-checked/kv:block col-span-full">
22
+
{{ template "kvLabelDef" . }}
42
23
</div>
43
24
44
-
<!-- Scope -->
25
+
<div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div>
26
+
</div>
27
+
{{ end }}
28
+
29
+
{{ define "basicLabelDef" }}
30
+
<form
31
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
32
+
hx-indicator="#spinner"
33
+
hx-swap="none"
34
+
hx-on::after-request="if(event.detail.successful) this.reset()"
35
+
class="flex flex-col space-y-4">
36
+
37
+
<p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p>
38
+
39
+
{{ template "nameInput" . }}
40
+
{{ template "scopeInput" . }}
41
+
{{ template "colorInput" . }}
42
+
43
+
<div class="flex gap-2 pt-2">
44
+
{{ template "cancelButton" . }}
45
+
{{ template "submitButton" . }}
46
+
</div>
47
+
</form>
48
+
{{ end }}
49
+
50
+
{{ define "kvLabelDef" }}
51
+
<form
52
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
53
+
hx-indicator="#spinner"
54
+
hx-swap="none"
55
+
hx-on::after-request="if(event.detail.successful) this.reset()"
56
+
class="flex flex-col space-y-4">
57
+
58
+
<p class="text-gray-500 dark:text-gray-400">
59
+
These labels are more detailed, they can have a key and an associated
60
+
value. You may define additional constraints on label values.
61
+
</p>
62
+
63
+
{{ template "nameInput" . }}
64
+
{{ template "valueInput" . }}
65
+
{{ template "multipleInput" . }}
66
+
{{ template "scopeInput" . }}
67
+
{{ template "colorInput" . }}
68
+
69
+
<div class="flex gap-2 pt-2">
70
+
{{ template "cancelButton" . }}
71
+
{{ template "submitButton" . }}
72
+
</div>
73
+
</form>
74
+
{{ end }}
75
+
76
+
{{ define "nameInput" }}
45
77
<div class="w-full">
46
-
<label for="scope">Scope</label>
47
-
<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">
48
-
<option value="sh.tangled.repo.issue">Issues</option>
49
-
<option value="sh.tangled.repo.pull">Pull Requests</option>
50
-
</select>
78
+
<label for="name">Name</label>
79
+
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
51
80
</div>
81
+
{{ end }}
52
82
53
-
<!-- Color -->
83
+
{{ define "colorInput" }}
54
84
<div class="w-full">
55
85
<label for="color">Color</label>
56
86
<div class="grid grid-cols-4 grid-rows-2 place-items-center">
···
63
93
{{ end }}
64
94
</div>
65
95
</div>
96
+
{{ end }}
66
97
67
-
<!-- Multiple -->
68
-
<div class="w-full flex flex-wrap gap-2">
69
-
<input type="checkbox" id="multiple" name="multiple" value="true" />
70
-
<span>
71
-
Allow multiple values
72
-
</span>
98
+
{{ define "scopeInput" }}
99
+
<div class="w-full">
100
+
<label>Scope</label>
101
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
102
+
<input type="checkbox" id="issue-scope" name="scope" value="sh.tangled.repo.issue" checked />
103
+
Issues
104
+
</label>
105
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
106
+
<input type="checkbox" id="pulls-scope" name="scope" value="sh.tangled.repo.pull" checked />
107
+
Pull Requests
108
+
</label>
109
+
</div>
110
+
{{ end }}
111
+
112
+
{{ define "valueInput" }}
113
+
<div class="w-full">
114
+
<label for="valueType">Value Type</label>
115
+
<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">
116
+
<option value="string">String</option>
117
+
<option value="integer">Integer</option>
118
+
</select>
73
119
</div>
74
120
75
-
<div class="flex gap-2 pt-2">
76
-
<button
77
-
type="button"
78
-
popovertarget="add-labeldef-modal"
79
-
popovertargetaction="hide"
80
-
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"
81
-
>
82
-
{{ i "x" "size-4" }} cancel
83
-
</button>
84
-
<button type="submit" class="btn w-1/2 flex items-center">
85
-
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
86
-
<span id="spinner" class="group">
87
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
88
-
</span>
89
-
</button>
121
+
<div class="w-full">
122
+
<label for="enumValues">Permitted values</label>
123
+
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
124
+
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">
125
+
Enter comma-separated list of permitted values, or leave empty to allow any value.
126
+
</p>
90
127
</div>
91
-
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
92
-
</form>
93
-
94
-
<script>
95
-
document.getElementById('value-type').addEventListener('change', function() {
96
-
const constrainValues = document.getElementById('constrain-values');
97
-
const selectedValue = this.value;
98
-
99
-
if (selectedValue === 'string') {
100
-
constrainValues.classList.remove('hidden');
101
-
} else {
102
-
constrainValues.classList.add('hidden');
103
-
constrainValues.removeAttribute('open');
104
-
document.getElementById('enumValues').value = '';
105
-
}
106
-
});
107
-
108
-
function toggleDarkMode() {
109
-
document.documentElement.classList.toggle('dark');
110
-
}
111
-
</script>
128
+
129
+
<div class="w-full">
130
+
<label for="valueFormat">String format</label>
131
+
<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">
132
+
<option value="any" selected>Any</option>
133
+
<option value="did">DID</option>
134
+
</select>
135
+
</div>
136
+
{{ end }}
137
+
138
+
{{ define "multipleInput" }}
139
+
<div class="w-full flex flex-wrap gap-2">
140
+
<input type="checkbox" id="multiple" name="multiple" value="true" />
141
+
<span>Allow multiple values</span>
142
+
</div>
112
143
{{ end }}
113
144
145
+
{{ define "cancelButton" }}
146
+
<button
147
+
type="button"
148
+
popovertarget="add-labeldef-modal"
149
+
popovertargetaction="hide"
150
+
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"
151
+
>
152
+
{{ i "x" "size-4" }} cancel
153
+
</button>
154
+
{{ end }}
155
+
156
+
{{ define "submitButton" }}
157
+
<button type="submit" class="btn-create w-1/2 flex items-center">
158
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
159
+
<span id="spinner" class="group">
160
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
161
+
</span>
162
+
</button>
163
+
{{ end }}
164
+
165
+
+25
-27
appview/pages/templates/repo/settings/fragments/labelListing.html
+25
-27
appview/pages/templates/repo/settings/fragments/labelListing.html
···
1
1
{{ define "repo/settings/fragments/labelListing" }}
2
2
{{ $root := index . 0 }}
3
3
{{ $label := index . 1 }}
4
-
<div id="label-{{$label.Id}}" class="flex items-center justify-between p-2 pl-4">
5
-
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
6
-
{{ template "labels/fragments/labelDef" $label }}
7
-
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
4
+
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
5
+
{{ template "labels/fragments/labelDef" $label }}
6
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
7
+
{{ if $label.ValueType.IsNull }}
8
+
basic
9
+
{{ else }}
8
10
{{ $label.ValueType.Type }} type
9
-
{{ if $label.ValueType.IsEnumType }}
10
-
<span class="before:content-['·'] before:select-none"></span>
11
-
{{ join $label.ValueType.Enum ", " }}
12
-
{{ end }}
13
-
{{ if $label.ValueType.IsDidFormat }}
14
-
<span class="before:content-['·'] before:select-none"></span>
15
-
DID format
16
-
{{ end }}
17
-
</div>
11
+
{{ end }}
12
+
13
+
{{ if $label.ValueType.IsEnum }}
14
+
<span class="before:content-['·'] before:select-none"></span>
15
+
{{ join $label.ValueType.Enum ", " }}
16
+
{{ end }}
17
+
18
+
{{ if $label.ValueType.IsDidFormat }}
19
+
<span class="before:content-['·'] before:select-none"></span>
20
+
DID format
21
+
{{ end }}
22
+
23
+
{{ if $label.Multiple }}
24
+
<span class="before:content-['·'] before:select-none"></span>
25
+
multiple
26
+
{{ end }}
27
+
28
+
<span class="before:content-['·'] before:select-none"></span>
29
+
{{ join $label.Scope ", " }}
18
30
</div>
19
-
{{ if $root.RepoInfo.Roles.IsOwner }}
20
-
<button
21
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
22
-
title="Delete label"
23
-
hx-delete="/{{ $root.RepoInfo.FullName }}/settings/label"
24
-
hx-swap="none"
25
-
hx-vals='{"label-id": "{{ $label.Id }}"}'
26
-
hx-confirm="Are you sure you want to delete the label `{{ $label.Name }}`?"
27
-
>
28
-
{{ i "trash-2" "w-5 h-5" }}
29
-
<span class="hidden md:inline">delete</span>
30
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
-
</button>
32
-
{{ end }}
33
31
</div>
34
32
{{ end }}
+9
consts/consts.go
+9
consts/consts.go
+30
appview/models/artifact.go
+30
appview/models/artifact.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/go-git/go-git/v5/plumbing"
9
+
"github.com/ipfs/go-cid"
10
+
"tangled.org/core/api/tangled"
11
+
)
12
+
13
+
type Artifact struct {
14
+
Id uint64
15
+
Did string
16
+
Rkey string
17
+
18
+
RepoAt syntax.ATURI
19
+
Tag plumbing.Hash
20
+
CreatedAt time.Time
21
+
22
+
BlobCid cid.Cid
23
+
Name string
24
+
Size uint64
25
+
MimeType string
26
+
}
27
+
28
+
func (a *Artifact) ArtifactAt() syntax.ATURI {
29
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
30
+
}
+21
appview/models/collaborator.go
+21
appview/models/collaborator.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type Collaborator struct {
10
+
// identifiers for the record
11
+
Id int64
12
+
Did syntax.DID
13
+
Rkey string
14
+
15
+
// content
16
+
SubjectDid syntax.DID
17
+
RepoAt syntax.ATURI
18
+
19
+
// meta
20
+
Created time.Time
21
+
}
+16
appview/models/email.go
+16
appview/models/email.go
+26
-57
appview/db/follow.go
+26
-57
appview/db/follow.go
···
5
5
"log"
6
6
"strings"
7
7
"time"
8
-
)
9
8
10
-
type Follow struct {
11
-
UserDid string
12
-
SubjectDid string
13
-
FollowedAt time.Time
14
-
Rkey string
15
-
}
9
+
"tangled.org/core/appview/models"
10
+
)
16
11
17
-
func AddFollow(e Execer, follow *Follow) error {
12
+
func AddFollow(e Execer, follow *models.Follow) error {
18
13
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
19
14
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
20
15
return err
21
16
}
22
17
23
18
// Get a follow record
24
-
func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) {
19
+
func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) {
25
20
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
26
21
row := e.QueryRow(query, userDid, subjectDid)
27
22
28
-
var follow Follow
23
+
var follow models.Follow
29
24
var followedAt string
30
25
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
31
26
if err != nil {
···
55
50
return err
56
51
}
57
52
58
-
type FollowStats struct {
59
-
Followers int64
60
-
Following int64
61
-
}
62
-
63
-
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
53
+
func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) {
64
54
var followers, following int64
65
55
err := e.QueryRow(
66
56
`SELECT
···
68
58
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
69
59
FROM follows;`, did, did).Scan(&followers, &following)
70
60
if err != nil {
71
-
return FollowStats{}, err
61
+
return models.FollowStats{}, err
72
62
}
73
-
return FollowStats{
63
+
return models.FollowStats{
74
64
Followers: followers,
75
65
Following: following,
76
66
}, nil
77
67
}
78
68
79
-
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
69
+
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) {
80
70
if len(dids) == 0 {
81
71
return nil, nil
82
72
}
···
112
102
) g on f.did = g.did`,
113
103
placeholderStr, placeholderStr)
114
104
115
-
result := make(map[string]FollowStats)
105
+
result := make(map[string]models.FollowStats)
116
106
117
107
rows, err := e.Query(query, args...)
118
108
if err != nil {
···
126
116
if err := rows.Scan(&did, &followers, &following); err != nil {
127
117
return nil, err
128
118
}
129
-
result[did] = FollowStats{
119
+
result[did] = models.FollowStats{
130
120
Followers: followers,
131
121
Following: following,
132
122
}
···
134
124
135
125
for _, did := range dids {
136
126
if _, exists := result[did]; !exists {
137
-
result[did] = FollowStats{
127
+
result[did] = models.FollowStats{
138
128
Followers: 0,
139
129
Following: 0,
140
130
}
···
144
134
return result, nil
145
135
}
146
136
147
-
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
148
-
var follows []Follow
137
+
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
138
+
var follows []models.Follow
149
139
150
140
var conditions []string
151
141
var args []any
···
177
167
return nil, err
178
168
}
179
169
for rows.Next() {
180
-
var follow Follow
170
+
var follow models.Follow
181
171
var followedAt string
182
172
err := rows.Scan(
183
173
&follow.UserDid,
···
200
190
return follows, nil
201
191
}
202
192
203
-
func GetFollowers(e Execer, did string) ([]Follow, error) {
193
+
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
204
194
return GetFollows(e, 0, FilterEq("subject_did", did))
205
195
}
206
196
207
-
func GetFollowing(e Execer, did string) ([]Follow, error) {
197
+
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
208
198
return GetFollows(e, 0, FilterEq("user_did", did))
209
199
}
210
200
211
-
type FollowStatus int
212
-
213
-
const (
214
-
IsNotFollowing FollowStatus = iota
215
-
IsFollowing
216
-
IsSelf
217
-
)
218
-
219
-
func (s FollowStatus) String() string {
220
-
switch s {
221
-
case IsNotFollowing:
222
-
return "IsNotFollowing"
223
-
case IsFollowing:
224
-
return "IsFollowing"
225
-
case IsSelf:
226
-
return "IsSelf"
227
-
default:
228
-
return "IsNotFollowing"
229
-
}
230
-
}
231
-
232
-
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
201
+
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
233
202
if len(subjectDids) == 0 || userDid == "" {
234
-
return make(map[string]FollowStatus), nil
203
+
return make(map[string]models.FollowStatus), nil
235
204
}
236
205
237
-
result := make(map[string]FollowStatus)
206
+
result := make(map[string]models.FollowStatus)
238
207
239
208
for _, subjectDid := range subjectDids {
240
209
if userDid == subjectDid {
241
-
result[subjectDid] = IsSelf
210
+
result[subjectDid] = models.IsSelf
242
211
} else {
243
-
result[subjectDid] = IsNotFollowing
212
+
result[subjectDid] = models.IsNotFollowing
244
213
}
245
214
}
246
215
···
281
250
if err := rows.Scan(&subjectDid); err != nil {
282
251
return nil, err
283
252
}
284
-
result[subjectDid] = IsFollowing
253
+
result[subjectDid] = models.IsFollowing
285
254
}
286
255
287
256
return result, nil
288
257
}
289
258
290
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
259
+
func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus {
291
260
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
292
261
if err != nil {
293
-
return IsNotFollowing
262
+
return models.IsNotFollowing
294
263
}
295
264
return statuses[subjectDid]
296
265
}
297
266
298
-
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
267
+
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
299
268
return getFollowStatuses(e, userDid, subjectDids)
300
269
}
+38
appview/models/follow.go
+38
appview/models/follow.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
)
6
+
7
+
type Follow struct {
8
+
UserDid string
9
+
SubjectDid string
10
+
FollowedAt time.Time
11
+
Rkey string
12
+
}
13
+
14
+
type FollowStats struct {
15
+
Followers int64
16
+
Following int64
17
+
}
18
+
19
+
type FollowStatus int
20
+
21
+
const (
22
+
IsNotFollowing FollowStatus = iota
23
+
IsFollowing
24
+
IsSelf
25
+
)
26
+
27
+
func (s FollowStatus) String() string {
28
+
switch s {
29
+
case IsNotFollowing:
30
+
return "IsNotFollowing"
31
+
case IsFollowing:
32
+
return "IsFollowing"
33
+
case IsSelf:
34
+
return "IsSelf"
35
+
default:
36
+
return "IsNotFollowing"
37
+
}
38
+
}
+3
-3
appview/pages/repoinfo/repoinfo.go
+3
-3
appview/pages/repoinfo/repoinfo.go
···
7
7
"strings"
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.org/core/appview/db"
10
+
"tangled.org/core/appview/models"
11
11
"tangled.org/core/appview/state/userutil"
12
12
)
13
13
···
60
60
Spindle string
61
61
RepoAt syntax.ATURI
62
62
IsStarred bool
63
-
Stats db.RepoStats
63
+
Stats models.RepoStats
64
64
Roles RolesInRepo
65
-
Source *db.Repo
65
+
Source *models.Repo
66
66
SourceHandle string
67
67
Ref string
68
68
DisableFork bool
+4
-4
appview/state/git_http.go
+4
-4
appview/state/git_http.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/identity"
10
10
"github.com/go-chi/chi/v5"
11
-
"tangled.org/core/appview/db"
11
+
"tangled.org/core/appview/models"
12
12
)
13
13
14
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
15
15
user := r.Context().Value("resolvedId").(identity.Identity)
16
-
repo := r.Context().Value("repo").(*db.Repo)
16
+
repo := r.Context().Value("repo").(*models.Repo)
17
17
18
18
scheme := "https"
19
19
if s.config.Core.Dev {
···
31
31
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
32
return
33
33
}
34
-
repo := r.Context().Value("repo").(*db.Repo)
34
+
repo := r.Context().Value("repo").(*models.Repo)
35
35
36
36
scheme := "https"
37
37
if s.config.Core.Dev {
···
48
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
49
return
50
50
}
51
-
repo := r.Context().Value("repo").(*db.Repo)
51
+
repo := r.Context().Value("repo").(*models.Repo)
52
52
53
53
scheme := "https"
54
54
if s.config.Core.Dev {
+15
-200
appview/db/issues.go
+15
-200
appview/db/issues.go
···
10
10
"time"
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.org/core/api/tangled"
14
13
"tangled.org/core/appview/models"
15
14
"tangled.org/core/appview/pagination"
16
15
)
17
16
18
-
type Issue struct {
19
-
Id int64
20
-
Did string
21
-
Rkey string
22
-
RepoAt syntax.ATURI
23
-
IssueId int
24
-
Created time.Time
25
-
Edited *time.Time
26
-
Deleted *time.Time
27
-
Title string
28
-
Body string
29
-
Open bool
30
-
31
-
// optionally, populate this when querying for reverse mappings
32
-
// like comment counts, parent repo etc.
33
-
Comments []IssueComment
34
-
Labels models.LabelState
35
-
Repo *models.Repo
36
-
}
37
-
38
-
func (i *Issue) AtUri() syntax.ATURI {
39
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
40
-
}
41
-
42
-
func (i *Issue) AsRecord() tangled.RepoIssue {
43
-
return tangled.RepoIssue{
44
-
Repo: i.RepoAt.String(),
45
-
Title: i.Title,
46
-
Body: &i.Body,
47
-
CreatedAt: i.Created.Format(time.RFC3339),
48
-
}
49
-
}
50
-
51
-
func (i *Issue) State() string {
52
-
if i.Open {
53
-
return "open"
54
-
}
55
-
return "closed"
56
-
}
57
-
58
-
type CommentListItem struct {
59
-
Self *IssueComment
60
-
Replies []*IssueComment
61
-
}
62
-
63
-
func (i *Issue) CommentList() []CommentListItem {
64
-
// Create a map to quickly find comments by their aturi
65
-
toplevel := make(map[string]*CommentListItem)
66
-
var replies []*IssueComment
67
-
68
-
// collect top level comments into the map
69
-
for _, comment := range i.Comments {
70
-
if comment.IsTopLevel() {
71
-
toplevel[comment.AtUri().String()] = &CommentListItem{
72
-
Self: &comment,
73
-
}
74
-
} else {
75
-
replies = append(replies, &comment)
76
-
}
77
-
}
78
-
79
-
for _, r := range replies {
80
-
parentAt := *r.ReplyTo
81
-
if parent, exists := toplevel[parentAt]; exists {
82
-
parent.Replies = append(parent.Replies, r)
83
-
}
84
-
}
85
-
86
-
var listing []CommentListItem
87
-
for _, v := range toplevel {
88
-
listing = append(listing, *v)
89
-
}
90
-
91
-
// sort everything
92
-
sortFunc := func(a, b *IssueComment) bool {
93
-
return a.Created.Before(b.Created)
94
-
}
95
-
sort.Slice(listing, func(i, j int) bool {
96
-
return sortFunc(listing[i].Self, listing[j].Self)
97
-
})
98
-
for _, r := range listing {
99
-
sort.Slice(r.Replies, func(i, j int) bool {
100
-
return sortFunc(r.Replies[i], r.Replies[j])
101
-
})
102
-
}
103
-
104
-
return listing
105
-
}
106
-
107
-
func (i *Issue) Participants() []string {
108
-
participantSet := make(map[string]struct{})
109
-
participants := []string{}
110
-
111
-
addParticipant := func(did string) {
112
-
if _, exists := participantSet[did]; !exists {
113
-
participantSet[did] = struct{}{}
114
-
participants = append(participants, did)
115
-
}
116
-
}
117
-
118
-
addParticipant(i.Did)
119
-
120
-
for _, c := range i.Comments {
121
-
addParticipant(c.Did)
122
-
}
123
-
124
-
return participants
125
-
}
126
-
127
-
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
128
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
129
-
if err != nil {
130
-
created = time.Now()
131
-
}
132
-
133
-
body := ""
134
-
if record.Body != nil {
135
-
body = *record.Body
136
-
}
137
-
138
-
return Issue{
139
-
RepoAt: syntax.ATURI(record.Repo),
140
-
Did: did,
141
-
Rkey: rkey,
142
-
Created: created,
143
-
Title: record.Title,
144
-
Body: body,
145
-
Open: true, // new issues are open by default
146
-
}
147
-
}
148
-
149
-
type IssueComment struct {
150
-
Id int64
151
-
Did string
152
-
Rkey string
153
-
IssueAt string
154
-
ReplyTo *string
155
-
Body string
156
-
Created time.Time
157
-
Edited *time.Time
158
-
Deleted *time.Time
159
-
}
160
-
161
-
func (i *IssueComment) AtUri() syntax.ATURI {
162
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
163
-
}
164
-
165
-
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
166
-
return tangled.RepoIssueComment{
167
-
Body: i.Body,
168
-
Issue: i.IssueAt,
169
-
CreatedAt: i.Created.Format(time.RFC3339),
170
-
ReplyTo: i.ReplyTo,
171
-
}
172
-
}
173
-
174
-
func (i *IssueComment) IsTopLevel() bool {
175
-
return i.ReplyTo == nil
176
-
}
177
-
178
-
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
179
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
180
-
if err != nil {
181
-
created = time.Now()
182
-
}
183
-
184
-
ownerDid := did
185
-
186
-
if _, err = syntax.ParseATURI(record.Issue); err != nil {
187
-
return nil, err
188
-
}
189
-
190
-
comment := IssueComment{
191
-
Did: ownerDid,
192
-
Rkey: rkey,
193
-
Body: record.Body,
194
-
IssueAt: record.Issue,
195
-
ReplyTo: record.ReplyTo,
196
-
Created: created,
197
-
}
198
-
199
-
return &comment, nil
200
-
}
201
-
202
-
func PutIssue(tx *sql.Tx, issue *Issue) error {
17
+
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
203
18
// ensure sequence exists
204
19
_, err := tx.Exec(`
205
20
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
234
49
}
235
50
}
236
51
237
-
func createNewIssue(tx *sql.Tx, issue *Issue) error {
52
+
func createNewIssue(tx *sql.Tx, issue *models.Issue) error {
238
53
// get next issue_id
239
54
var newIssueId int
240
55
err := tx.QueryRow(`
···
257
72
return row.Scan(&issue.Id, &issue.IssueId)
258
73
}
259
74
260
-
func updateIssue(tx *sql.Tx, issue *Issue) error {
75
+
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
261
76
// update existing issue
262
77
_, err := tx.Exec(`
263
78
update issues
···
267
82
return err
268
83
}
269
84
270
-
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
271
-
issueMap := make(map[string]*Issue) // at-uri -> issue
85
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
86
+
issueMap := make(map[string]*models.Issue) // at-uri -> issue
272
87
273
88
var conditions []string
274
89
var args []any
···
323
138
defer rows.Close()
324
139
325
140
for rows.Next() {
326
-
var issue Issue
141
+
var issue models.Issue
327
142
var createdAt string
328
143
var editedAt, deletedAt sql.Null[string]
329
144
var rowNum int64
···
416
231
}
417
232
}
418
233
419
-
var issues []Issue
234
+
var issues []models.Issue
420
235
for _, i := range issueMap {
421
236
issues = append(issues, *i)
422
237
}
···
428
243
return issues, nil
429
244
}
430
245
431
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
246
+
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
432
247
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
433
248
}
434
249
435
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
250
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
436
251
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
437
252
row := e.QueryRow(query, repoAt, issueId)
438
253
439
-
var issue Issue
254
+
var issue models.Issue
440
255
var createdAt string
441
256
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
442
257
if err != nil {
···
452
267
return &issue, nil
453
268
}
454
269
455
-
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
270
+
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
456
271
result, err := e.Exec(
457
272
`insert into issue_comments (
458
273
did,
···
514
329
return err
515
330
}
516
331
517
-
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
518
-
var comments []IssueComment
332
+
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
333
+
var comments []models.IssueComment
519
334
520
335
var conditions []string
521
336
var args []any
···
551
366
}
552
367
553
368
for rows.Next() {
554
-
var comment IssueComment
369
+
var comment models.IssueComment
555
370
var created string
556
371
var rkey, edited, deleted, replyTo sql.Null[string]
557
372
err := rows.Scan(
···
670
485
671
486
var count models.IssueCount
672
487
if err := row.Scan(&count.Open, &count.Closed); err != nil {
673
-
return models.IssueCount{0, 0}, err
488
+
return models.IssueCount{}, err
674
489
}
675
490
676
491
return count, nil
+194
appview/models/issue.go
+194
appview/models/issue.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"sort"
6
+
"time"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
+
)
11
+
12
+
type Issue struct {
13
+
Id int64
14
+
Did string
15
+
Rkey string
16
+
RepoAt syntax.ATURI
17
+
IssueId int
18
+
Created time.Time
19
+
Edited *time.Time
20
+
Deleted *time.Time
21
+
Title string
22
+
Body string
23
+
Open bool
24
+
25
+
// optionally, populate this when querying for reverse mappings
26
+
// like comment counts, parent repo etc.
27
+
Comments []IssueComment
28
+
Labels LabelState
29
+
Repo *Repo
30
+
}
31
+
32
+
func (i *Issue) AtUri() syntax.ATURI {
33
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
34
+
}
35
+
36
+
func (i *Issue) AsRecord() tangled.RepoIssue {
37
+
return tangled.RepoIssue{
38
+
Repo: i.RepoAt.String(),
39
+
Title: i.Title,
40
+
Body: &i.Body,
41
+
CreatedAt: i.Created.Format(time.RFC3339),
42
+
}
43
+
}
44
+
45
+
func (i *Issue) State() string {
46
+
if i.Open {
47
+
return "open"
48
+
}
49
+
return "closed"
50
+
}
51
+
52
+
type CommentListItem struct {
53
+
Self *IssueComment
54
+
Replies []*IssueComment
55
+
}
56
+
57
+
func (i *Issue) CommentList() []CommentListItem {
58
+
// Create a map to quickly find comments by their aturi
59
+
toplevel := make(map[string]*CommentListItem)
60
+
var replies []*IssueComment
61
+
62
+
// collect top level comments into the map
63
+
for _, comment := range i.Comments {
64
+
if comment.IsTopLevel() {
65
+
toplevel[comment.AtUri().String()] = &CommentListItem{
66
+
Self: &comment,
67
+
}
68
+
} else {
69
+
replies = append(replies, &comment)
70
+
}
71
+
}
72
+
73
+
for _, r := range replies {
74
+
parentAt := *r.ReplyTo
75
+
if parent, exists := toplevel[parentAt]; exists {
76
+
parent.Replies = append(parent.Replies, r)
77
+
}
78
+
}
79
+
80
+
var listing []CommentListItem
81
+
for _, v := range toplevel {
82
+
listing = append(listing, *v)
83
+
}
84
+
85
+
// sort everything
86
+
sortFunc := func(a, b *IssueComment) bool {
87
+
return a.Created.Before(b.Created)
88
+
}
89
+
sort.Slice(listing, func(i, j int) bool {
90
+
return sortFunc(listing[i].Self, listing[j].Self)
91
+
})
92
+
for _, r := range listing {
93
+
sort.Slice(r.Replies, func(i, j int) bool {
94
+
return sortFunc(r.Replies[i], r.Replies[j])
95
+
})
96
+
}
97
+
98
+
return listing
99
+
}
100
+
101
+
func (i *Issue) Participants() []string {
102
+
participantSet := make(map[string]struct{})
103
+
participants := []string{}
104
+
105
+
addParticipant := func(did string) {
106
+
if _, exists := participantSet[did]; !exists {
107
+
participantSet[did] = struct{}{}
108
+
participants = append(participants, did)
109
+
}
110
+
}
111
+
112
+
addParticipant(i.Did)
113
+
114
+
for _, c := range i.Comments {
115
+
addParticipant(c.Did)
116
+
}
117
+
118
+
return participants
119
+
}
120
+
121
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
122
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
123
+
if err != nil {
124
+
created = time.Now()
125
+
}
126
+
127
+
body := ""
128
+
if record.Body != nil {
129
+
body = *record.Body
130
+
}
131
+
132
+
return Issue{
133
+
RepoAt: syntax.ATURI(record.Repo),
134
+
Did: did,
135
+
Rkey: rkey,
136
+
Created: created,
137
+
Title: record.Title,
138
+
Body: body,
139
+
Open: true, // new issues are open by default
140
+
}
141
+
}
142
+
143
+
type IssueComment struct {
144
+
Id int64
145
+
Did string
146
+
Rkey string
147
+
IssueAt string
148
+
ReplyTo *string
149
+
Body string
150
+
Created time.Time
151
+
Edited *time.Time
152
+
Deleted *time.Time
153
+
}
154
+
155
+
func (i *IssueComment) AtUri() syntax.ATURI {
156
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
157
+
}
158
+
159
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
160
+
return tangled.RepoIssueComment{
161
+
Body: i.Body,
162
+
Issue: i.IssueAt,
163
+
CreatedAt: i.Created.Format(time.RFC3339),
164
+
ReplyTo: i.ReplyTo,
165
+
}
166
+
}
167
+
168
+
func (i *IssueComment) IsTopLevel() bool {
169
+
return i.ReplyTo == nil
170
+
}
171
+
172
+
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
173
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
174
+
if err != nil {
175
+
created = time.Now()
176
+
}
177
+
178
+
ownerDid := did
179
+
180
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
181
+
return nil, err
182
+
}
183
+
184
+
comment := IssueComment{
185
+
Did: ownerDid,
186
+
Rkey: rkey,
187
+
Body: record.Body,
188
+
IssueAt: record.Issue,
189
+
ReplyTo: record.ReplyTo,
190
+
Created: created,
191
+
}
192
+
193
+
return &comment, nil
194
+
}
+14
appview/models/language.go
+14
appview/models/language.go
-173
appview/db/oauth.go
-173
appview/db/oauth.go
···
1
-
package db
2
-
3
-
type OAuthRequest struct {
4
-
ID uint
5
-
AuthserverIss string
6
-
Handle string
7
-
State string
8
-
Did string
9
-
PdsUrl string
10
-
PkceVerifier string
11
-
DpopAuthserverNonce string
12
-
DpopPrivateJwk string
13
-
}
14
-
15
-
func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error {
16
-
_, err := e.Exec(`
17
-
insert into oauth_requests (
18
-
auth_server_iss,
19
-
state,
20
-
handle,
21
-
did,
22
-
pds_url,
23
-
pkce_verifier,
24
-
dpop_auth_server_nonce,
25
-
dpop_private_jwk
26
-
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
27
-
oauthRequest.AuthserverIss,
28
-
oauthRequest.State,
29
-
oauthRequest.Handle,
30
-
oauthRequest.Did,
31
-
oauthRequest.PdsUrl,
32
-
oauthRequest.PkceVerifier,
33
-
oauthRequest.DpopAuthserverNonce,
34
-
oauthRequest.DpopPrivateJwk,
35
-
)
36
-
return err
37
-
}
38
-
39
-
func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) {
40
-
var req OAuthRequest
41
-
err := e.QueryRow(`
42
-
select
43
-
id,
44
-
auth_server_iss,
45
-
handle,
46
-
state,
47
-
did,
48
-
pds_url,
49
-
pkce_verifier,
50
-
dpop_auth_server_nonce,
51
-
dpop_private_jwk
52
-
from oauth_requests
53
-
where state = ?`, state).Scan(
54
-
&req.ID,
55
-
&req.AuthserverIss,
56
-
&req.Handle,
57
-
&req.State,
58
-
&req.Did,
59
-
&req.PdsUrl,
60
-
&req.PkceVerifier,
61
-
&req.DpopAuthserverNonce,
62
-
&req.DpopPrivateJwk,
63
-
)
64
-
return req, err
65
-
}
66
-
67
-
func DeleteOAuthRequestByState(e Execer, state string) error {
68
-
_, err := e.Exec(`
69
-
delete from oauth_requests
70
-
where state = ?`, state)
71
-
return err
72
-
}
73
-
74
-
type OAuthSession struct {
75
-
ID uint
76
-
Handle string
77
-
Did string
78
-
PdsUrl string
79
-
AccessJwt string
80
-
RefreshJwt string
81
-
AuthServerIss string
82
-
DpopPdsNonce string
83
-
DpopAuthserverNonce string
84
-
DpopPrivateJwk string
85
-
Expiry string
86
-
}
87
-
88
-
func SaveOAuthSession(e Execer, session OAuthSession) error {
89
-
_, err := e.Exec(`
90
-
insert into oauth_sessions (
91
-
did,
92
-
handle,
93
-
pds_url,
94
-
access_jwt,
95
-
refresh_jwt,
96
-
auth_server_iss,
97
-
dpop_auth_server_nonce,
98
-
dpop_private_jwk,
99
-
expiry
100
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
101
-
session.Did,
102
-
session.Handle,
103
-
session.PdsUrl,
104
-
session.AccessJwt,
105
-
session.RefreshJwt,
106
-
session.AuthServerIss,
107
-
session.DpopAuthserverNonce,
108
-
session.DpopPrivateJwk,
109
-
session.Expiry,
110
-
)
111
-
return err
112
-
}
113
-
114
-
func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error {
115
-
_, err := e.Exec(`
116
-
update oauth_sessions
117
-
set access_jwt = ?, refresh_jwt = ?, expiry = ?
118
-
where did = ?`,
119
-
accessJwt,
120
-
refreshJwt,
121
-
expiry,
122
-
did,
123
-
)
124
-
return err
125
-
}
126
-
127
-
func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) {
128
-
var session OAuthSession
129
-
err := e.QueryRow(`
130
-
select
131
-
id,
132
-
did,
133
-
handle,
134
-
pds_url,
135
-
access_jwt,
136
-
refresh_jwt,
137
-
auth_server_iss,
138
-
dpop_auth_server_nonce,
139
-
dpop_private_jwk,
140
-
expiry
141
-
from oauth_sessions
142
-
where did = ?`, did).Scan(
143
-
&session.ID,
144
-
&session.Did,
145
-
&session.Handle,
146
-
&session.PdsUrl,
147
-
&session.AccessJwt,
148
-
&session.RefreshJwt,
149
-
&session.AuthServerIss,
150
-
&session.DpopAuthserverNonce,
151
-
&session.DpopPrivateJwk,
152
-
&session.Expiry,
153
-
)
154
-
return &session, err
155
-
}
156
-
157
-
func DeleteOAuthSessionByDid(e Execer, did string) error {
158
-
_, err := e.Exec(`
159
-
delete from oauth_sessions
160
-
where did = ?`, did)
161
-
return err
162
-
}
163
-
164
-
func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error {
165
-
_, err := e.Exec(`
166
-
update oauth_sessions
167
-
set dpop_pds_nonce = ?
168
-
where did = ?`,
169
-
dpopPdsNonce,
170
-
did,
171
-
)
172
-
return err
173
-
}
+177
appview/models/profile.go
+177
appview/models/profile.go
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/api/tangled"
8
+
)
9
+
10
+
type Profile struct {
11
+
// ids
12
+
ID int
13
+
Did string
14
+
15
+
// data
16
+
Description string
17
+
IncludeBluesky bool
18
+
Location string
19
+
Links [5]string
20
+
Stats [2]VanityStat
21
+
PinnedRepos [6]syntax.ATURI
22
+
}
23
+
24
+
func (p Profile) IsLinksEmpty() bool {
25
+
for _, l := range p.Links {
26
+
if l != "" {
27
+
return false
28
+
}
29
+
}
30
+
return true
31
+
}
32
+
33
+
func (p Profile) IsStatsEmpty() bool {
34
+
for _, s := range p.Stats {
35
+
if s.Kind != "" {
36
+
return false
37
+
}
38
+
}
39
+
return true
40
+
}
41
+
42
+
func (p Profile) IsPinnedReposEmpty() bool {
43
+
for _, r := range p.PinnedRepos {
44
+
if r != "" {
45
+
return false
46
+
}
47
+
}
48
+
return true
49
+
}
50
+
51
+
type VanityStatKind string
52
+
53
+
const (
54
+
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
55
+
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
56
+
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
57
+
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
58
+
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
59
+
VanityStatRepositoryCount VanityStatKind = "repository-count"
60
+
)
61
+
62
+
func (v VanityStatKind) String() string {
63
+
switch v {
64
+
case VanityStatMergedPRCount:
65
+
return "Merged PRs"
66
+
case VanityStatClosedPRCount:
67
+
return "Closed PRs"
68
+
case VanityStatOpenPRCount:
69
+
return "Open PRs"
70
+
case VanityStatOpenIssueCount:
71
+
return "Open Issues"
72
+
case VanityStatClosedIssueCount:
73
+
return "Closed Issues"
74
+
case VanityStatRepositoryCount:
75
+
return "Repositories"
76
+
}
77
+
return ""
78
+
}
79
+
80
+
type VanityStat struct {
81
+
Kind VanityStatKind
82
+
Value uint64
83
+
}
84
+
85
+
func (p *Profile) ProfileAt() syntax.ATURI {
86
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
87
+
}
88
+
89
+
type RepoEvent struct {
90
+
Repo *Repo
91
+
Source *Repo
92
+
}
93
+
94
+
type ProfileTimeline struct {
95
+
ByMonth []ByMonth
96
+
}
97
+
98
+
func (p *ProfileTimeline) IsEmpty() bool {
99
+
if p == nil {
100
+
return true
101
+
}
102
+
103
+
for _, m := range p.ByMonth {
104
+
if !m.IsEmpty() {
105
+
return false
106
+
}
107
+
}
108
+
109
+
return true
110
+
}
111
+
112
+
type ByMonth struct {
113
+
RepoEvents []RepoEvent
114
+
IssueEvents IssueEvents
115
+
PullEvents PullEvents
116
+
}
117
+
118
+
func (b ByMonth) IsEmpty() bool {
119
+
return len(b.RepoEvents) == 0 &&
120
+
len(b.IssueEvents.Items) == 0 &&
121
+
len(b.PullEvents.Items) == 0
122
+
}
123
+
124
+
type IssueEvents struct {
125
+
Items []*Issue
126
+
}
127
+
128
+
type IssueEventStats struct {
129
+
Open int
130
+
Closed int
131
+
}
132
+
133
+
func (i IssueEvents) Stats() IssueEventStats {
134
+
var open, closed int
135
+
for _, issue := range i.Items {
136
+
if issue.Open {
137
+
open += 1
138
+
} else {
139
+
closed += 1
140
+
}
141
+
}
142
+
143
+
return IssueEventStats{
144
+
Open: open,
145
+
Closed: closed,
146
+
}
147
+
}
148
+
149
+
type PullEvents struct {
150
+
Items []*Pull
151
+
}
152
+
153
+
func (p PullEvents) Stats() PullEventStats {
154
+
var open, merged, closed int
155
+
for _, pull := range p.Items {
156
+
switch pull.State {
157
+
case PullOpen:
158
+
open += 1
159
+
case PullMerged:
160
+
merged += 1
161
+
case PullClosed:
162
+
closed += 1
163
+
}
164
+
}
165
+
166
+
return PullEventStats{
167
+
Open: open,
168
+
Merged: merged,
169
+
Closed: closed,
170
+
}
171
+
}
172
+
173
+
type PullEventStats struct {
174
+
Closed int
175
+
Open int
176
+
Merged int
177
+
}
+17
-139
appview/db/pipeline.go
+17
-139
appview/db/pipeline.go
···
6
6
"strings"
7
7
"time"
8
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"github.com/go-git/go-git/v5/plumbing"
11
-
spindle "tangled.org/core/spindle/models"
12
-
"tangled.org/core/workflow"
9
+
"tangled.org/core/appview/models"
13
10
)
14
11
15
-
type Pipeline struct {
16
-
Id int
17
-
Rkey string
18
-
Knot string
19
-
RepoOwner syntax.DID
20
-
RepoName string
21
-
TriggerId int
22
-
Sha string
23
-
Created time.Time
24
-
25
-
// populate when querying for reverse mappings
26
-
Trigger *Trigger
27
-
Statuses map[string]WorkflowStatus
28
-
}
29
-
30
-
type WorkflowStatus struct {
31
-
Data []PipelineStatus
32
-
}
33
-
34
-
func (w WorkflowStatus) Latest() PipelineStatus {
35
-
return w.Data[len(w.Data)-1]
36
-
}
37
-
38
-
// time taken by this workflow to reach an "end state"
39
-
func (w WorkflowStatus) TimeTaken() time.Duration {
40
-
var start, end *time.Time
41
-
for _, s := range w.Data {
42
-
if s.Status.IsStart() {
43
-
start = &s.Created
44
-
}
45
-
if s.Status.IsFinish() {
46
-
end = &s.Created
47
-
}
48
-
}
49
-
50
-
if start != nil && end != nil && end.After(*start) {
51
-
return end.Sub(*start)
52
-
}
53
-
54
-
return 0
55
-
}
56
-
57
-
func (p Pipeline) Counts() map[string]int {
58
-
m := make(map[string]int)
59
-
for _, w := range p.Statuses {
60
-
m[w.Latest().Status.String()] += 1
61
-
}
62
-
return m
63
-
}
64
-
65
-
func (p Pipeline) TimeTaken() time.Duration {
66
-
var s time.Duration
67
-
for _, w := range p.Statuses {
68
-
s += w.TimeTaken()
69
-
}
70
-
return s
71
-
}
72
-
73
-
func (p Pipeline) Workflows() []string {
74
-
var ws []string
75
-
for v := range p.Statuses {
76
-
ws = append(ws, v)
77
-
}
78
-
slices.Sort(ws)
79
-
return ws
80
-
}
81
-
82
-
// if we know that a spindle has picked up this pipeline, then it is Responding
83
-
func (p Pipeline) IsResponding() bool {
84
-
return len(p.Statuses) != 0
85
-
}
86
-
87
-
type Trigger struct {
88
-
Id int
89
-
Kind workflow.TriggerKind
90
-
91
-
// push trigger fields
92
-
PushRef *string
93
-
PushNewSha *string
94
-
PushOldSha *string
95
-
96
-
// pull request trigger fields
97
-
PRSourceBranch *string
98
-
PRTargetBranch *string
99
-
PRSourceSha *string
100
-
PRAction *string
101
-
}
102
-
103
-
func (t *Trigger) IsPush() bool {
104
-
return t != nil && t.Kind == workflow.TriggerKindPush
105
-
}
106
-
107
-
func (t *Trigger) IsPullRequest() bool {
108
-
return t != nil && t.Kind == workflow.TriggerKindPullRequest
109
-
}
110
-
111
-
func (t *Trigger) TargetRef() string {
112
-
if t.IsPush() {
113
-
return plumbing.ReferenceName(*t.PushRef).Short()
114
-
} else if t.IsPullRequest() {
115
-
return *t.PRTargetBranch
116
-
}
117
-
118
-
return ""
119
-
}
120
-
121
-
type PipelineStatus struct {
122
-
ID int
123
-
Spindle string
124
-
Rkey string
125
-
PipelineKnot string
126
-
PipelineRkey string
127
-
Created time.Time
128
-
Workflow string
129
-
Status spindle.StatusKind
130
-
Error *string
131
-
ExitCode int
132
-
}
133
-
134
-
func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) {
135
-
var pipelines []Pipeline
12
+
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
13
+
var pipelines []models.Pipeline
136
14
137
15
var conditions []string
138
16
var args []any
···
156
34
defer rows.Close()
157
35
158
36
for rows.Next() {
159
-
var pipeline Pipeline
37
+
var pipeline models.Pipeline
160
38
var createdAt string
161
39
err = rows.Scan(
162
40
&pipeline.Id,
···
185
63
return pipelines, nil
186
64
}
187
65
188
-
func AddPipeline(e Execer, pipeline Pipeline) error {
66
+
func AddPipeline(e Execer, pipeline models.Pipeline) error {
189
67
args := []any{
190
68
pipeline.Rkey,
191
69
pipeline.Knot,
···
216
94
return err
217
95
}
218
96
219
-
func AddTrigger(e Execer, trigger Trigger) (int64, error) {
97
+
func AddTrigger(e Execer, trigger models.Trigger) (int64, error) {
220
98
args := []any{
221
99
trigger.Kind,
222
100
trigger.PushRef,
···
252
130
return res.LastInsertId()
253
131
}
254
132
255
-
func AddPipelineStatus(e Execer, status PipelineStatus) error {
133
+
func AddPipelineStatus(e Execer, status models.PipelineStatus) error {
256
134
args := []any{
257
135
status.Spindle,
258
136
status.Rkey,
···
290
168
291
169
// this is a mega query, but the most useful one:
292
170
// get N pipelines, for each one get the latest status of its N workflows
293
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) {
171
+
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
294
172
var conditions []string
295
173
var args []any
296
174
for _, filter := range filters {
···
335
213
}
336
214
defer rows.Close()
337
215
338
-
pipelines := make(map[string]Pipeline)
216
+
pipelines := make(map[string]models.Pipeline)
339
217
for rows.Next() {
340
-
var p Pipeline
341
-
var t Trigger
218
+
var p models.Pipeline
219
+
var t models.Trigger
342
220
var created string
343
221
344
222
err := rows.Scan(
···
370
248
371
249
t.Id = p.TriggerId
372
250
p.Trigger = &t
373
-
p.Statuses = make(map[string]WorkflowStatus)
251
+
p.Statuses = make(map[string]models.WorkflowStatus)
374
252
375
253
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
376
254
pipelines[k] = p
···
409
287
defer rows.Close()
410
288
411
289
for rows.Next() {
412
-
var ps PipelineStatus
290
+
var ps models.PipelineStatus
413
291
var created string
414
292
415
293
err := rows.Scan(
···
442
320
}
443
321
statuses, _ := pipeline.Statuses[ps.Workflow]
444
322
if !ok {
445
-
pipeline.Statuses[ps.Workflow] = WorkflowStatus{}
323
+
pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{}
446
324
}
447
325
448
326
// append
···
453
331
pipelines[key] = pipeline
454
332
}
455
333
456
-
var all []Pipeline
334
+
var all []models.Pipeline
457
335
for _, p := range pipelines {
458
336
for _, s := range p.Statuses {
459
-
slices.SortFunc(s.Data, func(a, b PipelineStatus) int {
337
+
slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int {
460
338
if a.Created.After(b.Created) {
461
339
return 1
462
340
}
···
476
354
}
477
355
478
356
// sort pipelines by date
479
-
slices.SortFunc(all, func(a, b Pipeline) int {
357
+
slices.SortFunc(all, func(a, b models.Pipeline) int {
480
358
if a.Created.After(b.Created) {
481
359
return -1
482
360
}
+130
appview/models/pipeline.go
+130
appview/models/pipeline.go
···
1
+
package models
2
+
3
+
import (
4
+
"slices"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/go-git/go-git/v5/plumbing"
9
+
spindle "tangled.org/core/spindle/models"
10
+
"tangled.org/core/workflow"
11
+
)
12
+
13
+
type Pipeline struct {
14
+
Id int
15
+
Rkey string
16
+
Knot string
17
+
RepoOwner syntax.DID
18
+
RepoName string
19
+
TriggerId int
20
+
Sha string
21
+
Created time.Time
22
+
23
+
// populate when querying for reverse mappings
24
+
Trigger *Trigger
25
+
Statuses map[string]WorkflowStatus
26
+
}
27
+
28
+
type WorkflowStatus struct {
29
+
Data []PipelineStatus
30
+
}
31
+
32
+
func (w WorkflowStatus) Latest() PipelineStatus {
33
+
return w.Data[len(w.Data)-1]
34
+
}
35
+
36
+
// time taken by this workflow to reach an "end state"
37
+
func (w WorkflowStatus) TimeTaken() time.Duration {
38
+
var start, end *time.Time
39
+
for _, s := range w.Data {
40
+
if s.Status.IsStart() {
41
+
start = &s.Created
42
+
}
43
+
if s.Status.IsFinish() {
44
+
end = &s.Created
45
+
}
46
+
}
47
+
48
+
if start != nil && end != nil && end.After(*start) {
49
+
return end.Sub(*start)
50
+
}
51
+
52
+
return 0
53
+
}
54
+
55
+
func (p Pipeline) Counts() map[string]int {
56
+
m := make(map[string]int)
57
+
for _, w := range p.Statuses {
58
+
m[w.Latest().Status.String()] += 1
59
+
}
60
+
return m
61
+
}
62
+
63
+
func (p Pipeline) TimeTaken() time.Duration {
64
+
var s time.Duration
65
+
for _, w := range p.Statuses {
66
+
s += w.TimeTaken()
67
+
}
68
+
return s
69
+
}
70
+
71
+
func (p Pipeline) Workflows() []string {
72
+
var ws []string
73
+
for v := range p.Statuses {
74
+
ws = append(ws, v)
75
+
}
76
+
slices.Sort(ws)
77
+
return ws
78
+
}
79
+
80
+
// if we know that a spindle has picked up this pipeline, then it is Responding
81
+
func (p Pipeline) IsResponding() bool {
82
+
return len(p.Statuses) != 0
83
+
}
84
+
85
+
type Trigger struct {
86
+
Id int
87
+
Kind workflow.TriggerKind
88
+
89
+
// push trigger fields
90
+
PushRef *string
91
+
PushNewSha *string
92
+
PushOldSha *string
93
+
94
+
// pull request trigger fields
95
+
PRSourceBranch *string
96
+
PRTargetBranch *string
97
+
PRSourceSha *string
98
+
PRAction *string
99
+
}
100
+
101
+
func (t *Trigger) IsPush() bool {
102
+
return t != nil && t.Kind == workflow.TriggerKindPush
103
+
}
104
+
105
+
func (t *Trigger) IsPullRequest() bool {
106
+
return t != nil && t.Kind == workflow.TriggerKindPullRequest
107
+
}
108
+
109
+
func (t *Trigger) TargetRef() string {
110
+
if t.IsPush() {
111
+
return plumbing.ReferenceName(*t.PushRef).Short()
112
+
} else if t.IsPullRequest() {
113
+
return *t.PRTargetBranch
114
+
}
115
+
116
+
return ""
117
+
}
118
+
119
+
type PipelineStatus struct {
120
+
ID int
121
+
Spindle string
122
+
Rkey string
123
+
PipelineKnot string
124
+
PipelineRkey string
125
+
Created time.Time
126
+
Workflow string
127
+
Status spindle.StatusKind
128
+
Error *string
129
+
ExitCode int
130
+
}
+3
-2
appview/repo/repo_util.go
+3
-2
appview/repo/repo_util.go
···
10
10
"strings"
11
11
12
12
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
13
14
"tangled.org/core/appview/pages/repoinfo"
14
15
"tangled.org/core/types"
15
16
···
143
144
d *db.DB,
144
145
repoInfo repoinfo.RepoInfo,
145
146
shas []string,
146
-
) (map[string]db.Pipeline, error) {
147
-
m := make(map[string]db.Pipeline)
147
+
) (map[string]models.Pipeline, error) {
148
+
m := make(map[string]models.Pipeline)
148
149
149
150
if len(shas) == 0 {
150
151
return m, nil
+25
appview/models/pubkey.go
+25
appview/models/pubkey.go
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/json"
5
+
"time"
6
+
)
7
+
8
+
type PublicKey struct {
9
+
Did string `json:"did"`
10
+
Key string `json:"key"`
11
+
Name string `json:"name"`
12
+
Rkey string `json:"rkey"`
13
+
Created *time.Time
14
+
}
15
+
16
+
func (p PublicKey) MarshalJSON() ([]byte, error) {
17
+
type Alias PublicKey
18
+
return json.Marshal(&struct {
19
+
Created string `json:"created"`
20
+
*Alias
21
+
}{
22
+
Created: p.Created.Format(time.RFC3339),
23
+
Alias: (*Alias)(&p),
24
+
})
25
+
}
+14
appview/models/punchcard.go
+14
appview/models/punchcard.go
+44
appview/models/registration.go
+44
appview/models/registration.go
···
1
+
package models
2
+
3
+
import "time"
4
+
5
+
// Registration represents a knot registration. Knot would've been a better
6
+
// name but we're stuck with this for historical reasons.
7
+
type Registration struct {
8
+
Id int64
9
+
Domain string
10
+
ByDid string
11
+
Created *time.Time
12
+
Registered *time.Time
13
+
NeedsUpgrade bool
14
+
}
15
+
16
+
func (r *Registration) Status() Status {
17
+
if r.NeedsUpgrade {
18
+
return NeedsUpgrade
19
+
} else if r.Registered != nil {
20
+
return Registered
21
+
} else {
22
+
return Pending
23
+
}
24
+
}
25
+
26
+
func (r *Registration) IsRegistered() bool {
27
+
return r.Status() == Registered
28
+
}
29
+
30
+
func (r *Registration) IsNeedsUpgrade() bool {
31
+
return r.Status() == NeedsUpgrade
32
+
}
33
+
34
+
func (r *Registration) IsPending() bool {
35
+
return r.Status() == Pending
36
+
}
37
+
38
+
type Status uint32
39
+
40
+
const (
41
+
Registered Status = iota
42
+
Pending
43
+
NeedsUpgrade
44
+
)
+10
appview/models/signup.go
+10
appview/models/signup.go
+25
appview/models/spindle.go
+25
appview/models/spindle.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type Spindle struct {
10
+
Id int
11
+
Owner syntax.DID
12
+
Instance string
13
+
Verified *time.Time
14
+
Created time.Time
15
+
NeedsUpgrade bool
16
+
}
17
+
18
+
type SpindleMember struct {
19
+
Id int
20
+
Did syntax.DID // owner of the record
21
+
Rkey string // rkey of the record
22
+
Instance string
23
+
Subject syntax.DID // the member being added
24
+
Created time.Time
25
+
}
+17
appview/models/star.go
+17
appview/models/star.go
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type Star struct {
10
+
StarredByDid string
11
+
RepoAt syntax.ATURI
12
+
Created time.Time
13
+
Rkey string
14
+
15
+
// optionally, populate this when querying for reverse mappings
16
+
Repo *Repo
17
+
}
+95
appview/models/string.go
+95
appview/models/string.go
···
1
+
package models
2
+
3
+
import (
4
+
"bytes"
5
+
"fmt"
6
+
"io"
7
+
"strings"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"tangled.org/core/api/tangled"
12
+
)
13
+
14
+
type String struct {
15
+
Did syntax.DID
16
+
Rkey string
17
+
18
+
Filename string
19
+
Description string
20
+
Contents string
21
+
Created time.Time
22
+
Edited *time.Time
23
+
}
24
+
25
+
func (s *String) StringAt() syntax.ATURI {
26
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
27
+
}
28
+
29
+
func (s *String) AsRecord() tangled.String {
30
+
return tangled.String{
31
+
Filename: s.Filename,
32
+
Description: s.Description,
33
+
Contents: s.Contents,
34
+
CreatedAt: s.Created.Format(time.RFC3339),
35
+
}
36
+
}
37
+
38
+
func StringFromRecord(did, rkey string, record tangled.String) String {
39
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
40
+
if err != nil {
41
+
created = time.Now()
42
+
}
43
+
return String{
44
+
Did: syntax.DID(did),
45
+
Rkey: rkey,
46
+
Filename: record.Filename,
47
+
Description: record.Description,
48
+
Contents: record.Contents,
49
+
Created: created,
50
+
}
51
+
}
52
+
53
+
type StringStats struct {
54
+
LineCount uint64
55
+
ByteCount uint64
56
+
}
57
+
58
+
func (s String) Stats() StringStats {
59
+
lineCount, err := countLines(strings.NewReader(s.Contents))
60
+
if err != nil {
61
+
// non-fatal
62
+
// TODO: log this?
63
+
}
64
+
65
+
return StringStats{
66
+
LineCount: uint64(lineCount),
67
+
ByteCount: uint64(len(s.Contents)),
68
+
}
69
+
}
70
+
71
+
func countLines(r io.Reader) (int, error) {
72
+
buf := make([]byte, 32*1024)
73
+
bufLen := 0
74
+
count := 0
75
+
nl := []byte{'\n'}
76
+
77
+
for {
78
+
c, err := r.Read(buf)
79
+
if c > 0 {
80
+
bufLen += c
81
+
}
82
+
count += bytes.Count(buf[:c], nl)
83
+
84
+
switch {
85
+
case err == io.EOF:
86
+
/* handle last line not having a newline at the end */
87
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
88
+
count++
89
+
}
90
+
return count, nil
91
+
case err != nil:
92
+
return 0, err
93
+
}
94
+
}
95
+
}
+27
appview/validator/string.go
+27
appview/validator/string.go
···
1
+
package validator
2
+
3
+
import (
4
+
"errors"
5
+
"fmt"
6
+
"unicode/utf8"
7
+
8
+
"tangled.org/core/appview/models"
9
+
)
10
+
11
+
func (v *Validator) ValidateString(s *models.String) error {
12
+
var err error
13
+
14
+
if utf8.RuneCountInString(s.Filename) > 140 {
15
+
err = errors.Join(err, fmt.Errorf("filename too long"))
16
+
}
17
+
18
+
if utf8.RuneCountInString(s.Description) > 280 {
19
+
err = errors.Join(err, fmt.Errorf("description too long"))
20
+
}
21
+
22
+
if len(s.Contents) == 0 {
23
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
24
+
}
25
+
26
+
return err
27
+
}
+23
appview/models/timeline.go
+23
appview/models/timeline.go
···
1
+
package models
2
+
3
+
import "time"
4
+
5
+
type TimelineEvent struct {
6
+
*Repo
7
+
*Follow
8
+
*Star
9
+
10
+
EventAt time.Time
11
+
12
+
// optional: populate only if Repo is a fork
13
+
Source *Repo
14
+
15
+
// optional: populate only if event is Follow
16
+
*Profile
17
+
*FollowStats
18
+
*FollowStatus
19
+
20
+
// optional: populate only if event is Repo
21
+
IsStarred bool
22
+
StarCount int64
23
+
}
+1
-1
appview/db/label.go
+1
-1
appview/db/label.go
+1
-1
appview/reporesolver/resolver.go
+1
-1
appview/reporesolver/resolver.go
···
212
212
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
213
213
if u != nil {
214
214
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
215
-
return repoinfo.RolesInRepo{r}
215
+
return repoinfo.RolesInRepo{Roles: r}
216
216
} else {
217
217
return repoinfo.RolesInRepo{}
218
218
}
+36
-6
appview/pages/templates/repo/settings/general.html
+36
-6
appview/pages/templates/repo/settings/general.html
···
46
46
47
47
{{ define "defaultLabelSettings" }}
48
48
<div class="flex flex-col gap-2">
49
-
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
50
-
<p class="text-gray-500 dark:text-gray-400">
51
-
Manage your issues and pulls by creating labels to categorize them. Only
52
-
repository owners may configure labels. You may choose to subscribe to
53
-
default labels, or create entirely custom labels.
54
-
</p>
49
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
50
+
<div class="col-span-1 md:col-span-2">
51
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
52
+
<p class="text-gray-500 dark:text-gray-400">
53
+
Manage your issues and pulls by creating labels to categorize them. Only
54
+
repository owners may configure labels. You may choose to subscribe to
55
+
default labels, or create entirely custom labels.
56
+
<p>
57
+
</div>
58
+
<form class="col-span-1 md:col-span-1 md:justify-self-end">
59
+
{{ $title := "Unubscribe from all labels" }}
60
+
{{ $icon := "x" }}
61
+
{{ $text := "unsubscribe all" }}
62
+
{{ $action := "unsubscribe" }}
63
+
{{ if $.ShouldSubscribeAll }}
64
+
{{ $title = "Subscribe to all labels" }}
65
+
{{ $icon = "check-check" }}
66
+
{{ $text = "subscribe all" }}
67
+
{{ $action = "subscribe" }}
68
+
{{ end }}
69
+
{{ range .DefaultLabels }}
70
+
<input type="hidden" name="label" value="{{ .AtUri.String }}">
71
+
{{ end }}
72
+
<button
73
+
type="submit"
74
+
title="{{$title}}"
75
+
class="btn flex items-center gap-2 group"
76
+
hx-swap="none"
77
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
78
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}>
79
+
{{ i $icon "size-4" }}
80
+
{{ $text }}
81
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
+
</button>
83
+
</form>
84
+
</div>
55
85
<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">
56
86
{{ range .DefaultLabels }}
57
87
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+10
-33
appview/pages/templates/timeline/fragments/timeline.html
+10
-33
appview/pages/templates/timeline/fragments/timeline.html
···
82
82
{{ $event := index . 1 }}
83
83
{{ $follow := $event.Follow }}
84
84
{{ $profile := $event.Profile }}
85
-
{{ $stat := $event.FollowStats }}
85
+
{{ $followStats := $event.FollowStats }}
86
+
{{ $followStatus := $event.FollowStatus }}
86
87
87
88
{{ $userHandle := resolve $follow.UserDid }}
88
89
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
92
93
{{ template "user/fragments/picHandleLink" $subjectHandle }}
93
94
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
94
95
</div>
95
-
<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">
96
-
<div class="flex items-center gap-4 flex-1">
97
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
98
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
99
-
</div>
100
-
101
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
102
-
<a href="/{{ $subjectHandle }}">
103
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
104
-
</a>
105
-
{{ with $profile }}
106
-
{{ with .Description }}
107
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
108
-
{{ end }}
109
-
{{ end }}
110
-
{{ with $stat }}
111
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
112
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
113
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
114
-
<span class="select-none after:content-['·']"></span>
115
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
116
-
</div>
117
-
{{ end }}
118
-
</div>
119
-
</div>
120
-
121
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
122
-
<div class="flex-shrink-0 w-fit ml-auto">
123
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
124
-
</div>
125
-
{{ end }}
126
-
</div>
96
+
{{ template "user/fragments/followCard"
97
+
(dict
98
+
"LoggedInUser" $root.LoggedInUser
99
+
"UserDid" $follow.SubjectDid
100
+
"Profile" $profile
101
+
"FollowStatus" $followStatus
102
+
"FollowersCount" $followStats.Followers
103
+
"FollowingCount" $followStats.Following) }}
127
104
{{ end }}
+8
-1
appview/pages/templates/user/followers.html
+8
-1
appview/pages/templates/user/followers.html
···
10
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
12
{{ range .Followers }}
13
-
{{ template "user/fragments/followCard" . }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
14
21
{{ else }}
15
22
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
16
23
{{ end }}
+8
-1
appview/pages/templates/user/following.html
+8
-1
appview/pages/templates/user/following.html
···
10
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
12
{{ range .Following }}
13
-
{{ template "user/fragments/followCard" . }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
14
21
{{ else }}
15
22
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
16
23
{{ end }}
+6
-2
appview/pages/templates/user/fragments/follow.html
+6
-2
appview/pages/templates/user/fragments/follow.html
···
1
1
{{ define "user/fragments/follow" }}
2
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
-
class="btn mt-2 flex gap-2 items-center group"
3
+
class="btn w-full flex gap-2 items-center group"
4
4
5
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
6
hx-post="/follow?subject={{.UserDid}}"
···
12
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
13
hx-swap="outerHTML"
14
14
>
15
-
{{ 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 }}
15
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
16
+
{{ i "user-round-plus" "w-4 h-4" }} follow
17
+
{{ else }}
18
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
19
+
{{ end }}
16
20
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
17
21
</button>
18
22
{{ end }}
+58
-3
appview/posthog/notifier.go
appview/notify/posthog/notifier.go
+58
-3
appview/posthog/notifier.go
appview/notify/posthog/notifier.go
···
1
-
package posthog_service
1
+
package posthog
2
2
3
3
import (
4
4
"context"
···
98
98
}
99
99
}
100
100
101
+
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
102
+
err := n.client.Enqueue(posthog.Capture{
103
+
DistinctId: pull.OwnerDid,
104
+
Event: "pull_closed",
105
+
Properties: posthog.Properties{
106
+
"repo_at": pull.RepoAt,
107
+
"pull_id": pull.PullId,
108
+
},
109
+
})
110
+
if err != nil {
111
+
log.Println("failed to enqueue posthog event:", err)
112
+
}
113
+
}
114
+
101
115
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
102
116
err := n.client.Enqueue(posthog.Capture{
103
117
DistinctId: follow.UserDid,
···
152
166
}
153
167
}
154
168
155
-
func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) {
169
+
func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) {
156
170
err := n.client.Enqueue(posthog.Capture{
157
171
DistinctId: string.Did.String(),
158
-
Event: "create_string",
172
+
Event: "new_string",
159
173
Properties: posthog.Properties{"rkey": string.Rkey},
160
174
})
161
175
if err != nil {
162
176
log.Println("failed to enqueue posthog event:", err)
163
177
}
164
178
}
179
+
180
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
181
+
err := n.client.Enqueue(posthog.Capture{
182
+
DistinctId: comment.Did,
183
+
Event: "new_issue_comment",
184
+
Properties: posthog.Properties{
185
+
"issue_at": comment.IssueAt,
186
+
},
187
+
})
188
+
if err != nil {
189
+
log.Println("failed to enqueue posthog event:", err)
190
+
}
191
+
}
192
+
193
+
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
194
+
err := n.client.Enqueue(posthog.Capture{
195
+
DistinctId: issue.Did,
196
+
Event: "issue_closed",
197
+
Properties: posthog.Properties{
198
+
"repo_at": issue.RepoAt.String(),
199
+
"issue_id": issue.IssueId,
200
+
},
201
+
})
202
+
if err != nil {
203
+
log.Println("failed to enqueue posthog event:", err)
204
+
}
205
+
}
206
+
207
+
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
208
+
err := n.client.Enqueue(posthog.Capture{
209
+
DistinctId: pull.OwnerDid,
210
+
Event: "pull_merged",
211
+
Properties: posthog.Properties{
212
+
"repo_at": pull.RepoAt,
213
+
"pull_id": pull.PullId,
214
+
},
215
+
})
216
+
if err != nil {
217
+
log.Println("failed to enqueue posthog event:", err)
218
+
}
219
+
}
+36
-6
appview/db/repos.go
+36
-6
appview/db/repos.go
···
10
10
"time"
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
securejoin "github.com/cyphar/filepath-securejoin"
14
+
"tangled.org/core/api/tangled"
13
15
"tangled.org/core/appview/models"
14
16
)
15
17
18
+
type Repo struct {
19
+
Id int64
20
+
Did string
21
+
Name string
22
+
Knot string
23
+
Rkey string
24
+
Created time.Time
25
+
Description string
26
+
Spindle string
27
+
28
+
// optionally, populate this when querying for reverse mappings
29
+
RepoStats *models.RepoStats
30
+
31
+
// optional
32
+
Source string
33
+
}
34
+
35
+
func (r Repo) RepoAt() syntax.ATURI {
36
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
37
+
}
38
+
39
+
func (r Repo) DidSlashRepo() string {
40
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
41
+
return p
42
+
}
43
+
16
44
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
17
45
repoMap := make(map[syntax.ATURI]*models.Repo)
18
46
···
35
63
36
64
repoQuery := fmt.Sprintf(
37
65
`select
66
+
id,
38
67
did,
39
68
name,
40
69
knot,
···
63
92
var description, source, spindle sql.NullString
64
93
65
94
err := rows.Scan(
95
+
&repo.Id,
66
96
&repo.Did,
67
97
&repo.Name,
68
98
&repo.Knot,
···
327
357
var repo models.Repo
328
358
var nullableDescription sql.NullString
329
359
330
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
360
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
331
361
332
362
var createdAt string
333
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
363
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
334
364
return nil, err
335
365
}
336
366
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
386
416
var repos []models.Repo
387
417
388
418
rows, err := e.Query(
389
-
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
419
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
390
420
from repos r
391
421
left join collaborators c on r.at_uri = c.repo_at
392
422
where (r.did = ? or c.subject_did = ?)
···
406
436
var nullableDescription sql.NullString
407
437
var nullableSource sql.NullString
408
438
409
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
439
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
410
440
if err != nil {
411
441
return nil, err
412
442
}
···
443
473
var nullableSource sql.NullString
444
474
445
475
row := e.QueryRow(
446
-
`select did, name, knot, rkey, description, created, source
476
+
`select id, did, name, knot, rkey, description, created, source
447
477
from repos
448
478
where did = ? and name = ? and source is not null and source != ''`,
449
479
did, name,
450
480
)
451
481
452
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
482
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
453
483
if err != nil {
454
484
return nil, err
455
485
}
+12
appview/notify/merged_notifier.go
+12
appview/notify/merged_notifier.go
···
72
72
}
73
73
}
74
74
75
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
+
for _, notifier := range m.notifiers {
77
+
notifier.NewPullMerged(ctx, pull)
78
+
}
79
+
}
80
+
81
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
+
for _, notifier := range m.notifiers {
83
+
notifier.NewPullClosed(ctx, pull)
84
+
}
85
+
}
86
+
75
87
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
76
88
for _, notifier := range m.notifiers {
77
89
notifier.UpdateProfile(ctx, profile)
+4
-11
appview/pages/templates/errors/500.html
+4
-11
appview/pages/templates/errors/500.html
···
5
5
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
6
<div class="mb-6">
7
7
<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">
8
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
8
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
9
</div>
10
10
</div>
11
11
···
14
14
500 — internal server error
15
15
</h1>
16
16
<p class="text-gray-600 dark:text-gray-300">
17
-
Something went wrong on our end. We've been notified and are working to fix the issue.
18
-
</p>
19
-
<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">
20
-
<div class="flex items-center gap-2">
21
-
{{ i "info" "w-4 h-4" }}
22
-
<span class="font-medium">we're on it!</span>
23
-
</div>
24
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
-
</div>
17
+
We encountered an error while processing your request. Please try again later.
18
+
</p>
26
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
20
<button onclick="location.reload()" class="btn-create gap-2">
28
21
{{ i "refresh-cw" "w-4 h-4" }}
29
22
try again
30
23
</button>
31
24
<a href="/" class="btn no-underline hover:no-underline gap-2">
32
-
{{ i "home" "w-4 h-4" }}
25
+
{{ i "arrow-left" "w-4 h-4" }}
33
26
back to home
34
27
</a>
35
28
</div>
+173
appview/pages/templates/user/settings/notifications.html
+173
appview/pages/templates/user/settings/notifications.html
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "notificationSettings" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "notificationSettings" }}
20
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
21
+
<div class="col-span-1 md:col-span-2">
22
+
<h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2>
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Choose which notifications you want to receive when activity happens on your repositories and profile.
25
+
</p>
26
+
</div>
27
+
</div>
28
+
29
+
<form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6">
30
+
31
+
<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">
32
+
<div class="flex items-center justify-between p-2">
33
+
<div class="flex items-center gap-2">
34
+
<div class="flex flex-col gap-1">
35
+
<span class="font-bold">Repository starred</span>
36
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
37
+
<span>When someone stars your repository.</span>
38
+
</div>
39
+
</div>
40
+
</div>
41
+
<label class="flex items-center gap-2">
42
+
<input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}>
43
+
</label>
44
+
</div>
45
+
46
+
<div class="flex items-center justify-between p-2">
47
+
<div class="flex items-center gap-2">
48
+
<div class="flex flex-col gap-1">
49
+
<span class="font-bold">New issues</span>
50
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
51
+
<span>When someone creates an issue on your repository.</span>
52
+
</div>
53
+
</div>
54
+
</div>
55
+
<label class="flex items-center gap-2">
56
+
<input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}>
57
+
</label>
58
+
</div>
59
+
60
+
<div class="flex items-center justify-between p-2">
61
+
<div class="flex items-center gap-2">
62
+
<div class="flex flex-col gap-1">
63
+
<span class="font-bold">Issue comments</span>
64
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
65
+
<span>When someone comments on an issue you're involved with.</span>
66
+
</div>
67
+
</div>
68
+
</div>
69
+
<label class="flex items-center gap-2">
70
+
<input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}>
71
+
</label>
72
+
</div>
73
+
74
+
<div class="flex items-center justify-between p-2">
75
+
<div class="flex items-center gap-2">
76
+
<div class="flex flex-col gap-1">
77
+
<span class="font-bold">Issue closed</span>
78
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
79
+
<span>When an issue on your repository is closed.</span>
80
+
</div>
81
+
</div>
82
+
</div>
83
+
<label class="flex items-center gap-2">
84
+
<input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}>
85
+
</label>
86
+
</div>
87
+
88
+
<div class="flex items-center justify-between p-2">
89
+
<div class="flex items-center gap-2">
90
+
<div class="flex flex-col gap-1">
91
+
<span class="font-bold">New pull requests</span>
92
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
93
+
<span>When someone creates a pull request on your repository.</span>
94
+
</div>
95
+
</div>
96
+
</div>
97
+
<label class="flex items-center gap-2">
98
+
<input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}>
99
+
</label>
100
+
</div>
101
+
102
+
<div class="flex items-center justify-between p-2">
103
+
<div class="flex items-center gap-2">
104
+
<div class="flex flex-col gap-1">
105
+
<span class="font-bold">Pull request comments</span>
106
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
107
+
<span>When someone comments on a pull request you're involved with.</span>
108
+
</div>
109
+
</div>
110
+
</div>
111
+
<label class="flex items-center gap-2">
112
+
<input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}>
113
+
</label>
114
+
</div>
115
+
116
+
<div class="flex items-center justify-between p-2">
117
+
<div class="flex items-center gap-2">
118
+
<div class="flex flex-col gap-1">
119
+
<span class="font-bold">Pull request merged</span>
120
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
121
+
<span>When your pull request is merged.</span>
122
+
</div>
123
+
</div>
124
+
</div>
125
+
<label class="flex items-center gap-2">
126
+
<input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}>
127
+
</label>
128
+
</div>
129
+
130
+
<div class="flex items-center justify-between p-2">
131
+
<div class="flex items-center gap-2">
132
+
<div class="flex flex-col gap-1">
133
+
<span class="font-bold">New followers</span>
134
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
135
+
<span>When someone follows you.</span>
136
+
</div>
137
+
</div>
138
+
</div>
139
+
<label class="flex items-center gap-2">
140
+
<input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}>
141
+
</label>
142
+
</div>
143
+
144
+
<div class="flex items-center justify-between p-2">
145
+
<div class="flex items-center gap-2">
146
+
<div class="flex flex-col gap-1">
147
+
<span class="font-bold">Email notifications</span>
148
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
+
<span>Receive notifications via email in addition to in-app notifications.</span>
150
+
</div>
151
+
</div>
152
+
</div>
153
+
<label class="flex items-center gap-2">
154
+
<input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}>
155
+
</label>
156
+
</div>
157
+
</div>
158
+
159
+
<div class="flex justify-end pt-2">
160
+
<button
161
+
type="submit"
162
+
class="btn-create flex items-center gap-2 group"
163
+
>
164
+
{{ i "save" "w-4 h-4" }}
165
+
save
166
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
167
+
</button>
168
+
</div>
169
+
<div id="settings-notifications-success"></div>
170
+
171
+
<div id="settings-notifications-error" class="error"></div>
172
+
</form>
173
+
{{ end }}
+2
-2
appview/pages/templates/strings/put.html
+2
-2
appview/pages/templates/strings/put.html
···
3
3
{{ define "content" }}
4
4
<div class="px-6 py-2 mb-4">
5
5
{{ if eq .Action "new" }}
6
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
7
-
<p class="">Store and share code snippets with ease.</p>
6
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
8
8
{{ else }}
9
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
10
{{ end }}
+4
-1
appview/validator/validator.go
+4
-1
appview/validator/validator.go
···
4
4
"tangled.org/core/appview/db"
5
5
"tangled.org/core/appview/pages/markup"
6
6
"tangled.org/core/idresolver"
7
+
"tangled.org/core/rbac"
7
8
)
8
9
9
10
type Validator struct {
10
11
db *db.DB
11
12
sanitizer markup.Sanitizer
12
13
resolver *idresolver.Resolver
14
+
enforcer *rbac.Enforcer
13
15
}
14
16
15
-
func New(db *db.DB, res *idresolver.Resolver) *Validator {
17
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
16
18
return &Validator{
17
19
db: db,
18
20
sanitizer: markup.NewSanitizer(),
19
21
resolver: res,
22
+
enforcer: enforcer,
20
23
}
21
24
}
+1
-1
knotserver/xrpc/repo_blob.go
+1
-1
knotserver/xrpc/repo_blob.go
···
44
44
45
45
contents, err := gr.RawContent(treePath)
46
46
if err != nil {
47
-
x.Logger.Error("file content", "error", err.Error())
47
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
48
48
writeError(w, xrpcerr.NewXrpcError(
49
49
xrpcerr.WithTag("FileNotFound"),
50
50
xrpcerr.WithMessage("file not found at the specified path"),
+224
appview/pages/templates/brand/brand.html
+224
appview/pages/templates/brand/brand.html
···
1
+
{{ define "title" }}brand{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Assets and guidelines for using Tangled's logo and brand elements.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="space-y-16">
14
+
15
+
<!-- Introduction Section -->
16
+
<section>
17
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
18
+
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
+
follow the below guidelines when using Dolly and the logotype.
20
+
</p>
21
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
22
+
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
+
</p>
24
+
</section>
25
+
26
+
<!-- Black Logotype Section -->
27
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
28
+
<div class="order-2 lg:order-1">
29
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
30
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
31
+
alt="Tangled logo - black version"
32
+
class="w-full max-w-sm mx-auto" />
33
+
</div>
34
+
</div>
35
+
<div class="order-1 lg:order-2">
36
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
38
+
<p class="text-gray-700 dark:text-gray-300">
39
+
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
+
backgrounds and designs.
41
+
</p>
42
+
</div>
43
+
</section>
44
+
45
+
<!-- White Logotype Section -->
46
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
47
+
<div class="order-2 lg:order-1">
48
+
<div class="bg-black p-8 sm:p-16 rounded">
49
+
<img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg"
50
+
alt="Tangled logo - white version"
51
+
class="w-full max-w-sm mx-auto" />
52
+
</div>
53
+
</div>
54
+
<div class="order-1 lg:order-2">
55
+
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
57
+
<p class="text-gray-700 dark:text-gray-300">
58
+
This version features white text and elements, ideal for dark backgrounds
59
+
and inverted designs.
60
+
</p>
61
+
</div>
62
+
</section>
63
+
64
+
<!-- Mark Only Section -->
65
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
66
+
<div class="order-2 lg:order-1">
67
+
<div class="grid grid-cols-2 gap-2">
68
+
<!-- Black mark on light background -->
69
+
<div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded">
70
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
71
+
alt="Dolly face - black version"
72
+
class="w-full max-w-16 mx-auto" />
73
+
</div>
74
+
<!-- White mark on dark background -->
75
+
<div class="bg-black p-8 sm:p-12 rounded">
76
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
77
+
alt="Dolly face - white version"
78
+
class="w-full max-w-16 mx-auto" />
79
+
</div>
80
+
</div>
81
+
</div>
82
+
<div class="order-1 lg:order-2">
83
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
85
+
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
+
</p>
87
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
88
+
<strong class="font-semibold">Note</strong>: for situations where the background
89
+
is unknown, use the black version for ideal contrast in most environments.
90
+
</p>
91
+
</div>
92
+
</section>
93
+
94
+
<!-- Colored Backgrounds Section -->
95
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
96
+
<div class="order-2 lg:order-1">
97
+
<div class="grid grid-cols-2 gap-2">
98
+
<!-- Pastel Green background -->
99
+
<div class="bg-green-500 p-8 sm:p-12 rounded">
100
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
101
+
alt="Tangled logo on pastel green background"
102
+
class="w-full max-w-16 mx-auto" />
103
+
</div>
104
+
<!-- Pastel Blue background -->
105
+
<div class="bg-blue-500 p-8 sm:p-12 rounded">
106
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
107
+
alt="Tangled logo on pastel blue background"
108
+
class="w-full max-w-16 mx-auto" />
109
+
</div>
110
+
<!-- Pastel Yellow background -->
111
+
<div class="bg-yellow-500 p-8 sm:p-12 rounded">
112
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
113
+
alt="Tangled logo on pastel yellow background"
114
+
class="w-full max-w-16 mx-auto" />
115
+
</div>
116
+
<!-- Pastel Red background -->
117
+
<div class="bg-red-500 p-8 sm:p-12 rounded">
118
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
119
+
alt="Tangled logo on pastel red background"
120
+
class="w-full max-w-16 mx-auto" />
121
+
</div>
122
+
</div>
123
+
</div>
124
+
<div class="order-1 lg:order-2">
125
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
127
+
White logo mark on colored backgrounds.
128
+
</p>
129
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
130
+
The white logo mark provides contrast on colored backgrounds.
131
+
Perfect for more fun design contexts.
132
+
</p>
133
+
</div>
134
+
</section>
135
+
136
+
<!-- Black Logo on Pastel Backgrounds Section -->
137
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
138
+
<div class="order-2 lg:order-1">
139
+
<div class="grid grid-cols-2 gap-2">
140
+
<!-- Pastel Green background -->
141
+
<div class="bg-green-200 p-8 sm:p-12 rounded">
142
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
143
+
alt="Tangled logo on pastel green background"
144
+
class="w-full max-w-16 mx-auto" />
145
+
</div>
146
+
<!-- Pastel Blue background -->
147
+
<div class="bg-blue-200 p-8 sm:p-12 rounded">
148
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
149
+
alt="Tangled logo on pastel blue background"
150
+
class="w-full max-w-16 mx-auto" />
151
+
</div>
152
+
<!-- Pastel Yellow background -->
153
+
<div class="bg-yellow-200 p-8 sm:p-12 rounded">
154
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
155
+
alt="Tangled logo on pastel yellow background"
156
+
class="w-full max-w-16 mx-auto" />
157
+
</div>
158
+
<!-- Pastel Pink background -->
159
+
<div class="bg-pink-200 p-8 sm:p-12 rounded">
160
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
161
+
alt="Tangled logo on pastel pink background"
162
+
class="w-full max-w-16 mx-auto" />
163
+
</div>
164
+
</div>
165
+
</div>
166
+
<div class="order-1 lg:order-2">
167
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
169
+
Dark logo mark on lighter, pastel backgrounds.
170
+
</p>
171
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
172
+
The dark logo mark works beautifully on pastel backgrounds,
173
+
providing crisp contrast.
174
+
</p>
175
+
</div>
176
+
</section>
177
+
178
+
<!-- Recoloring Section -->
179
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
180
+
<div class="order-2 lg:order-1">
181
+
<div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded">
182
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
183
+
alt="Recolored Tangled logotype in gray/sand color"
184
+
class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" />
185
+
</div>
186
+
</div>
187
+
<div class="order-1 lg:order-2">
188
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
190
+
Custom coloring of the logotype is permitted.
191
+
</p>
192
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
193
+
Recoloring the logotype is allowed as long as readability is maintained.
194
+
</p>
195
+
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
+
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
+
</p>
198
+
</div>
199
+
</section>
200
+
201
+
<!-- Silhouette Section -->
202
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
203
+
<div class="order-2 lg:order-1">
204
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
205
+
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
206
+
alt="Dolly silhouette"
207
+
class="w-full max-w-32 mx-auto" />
208
+
</div>
209
+
</div>
210
+
<div class="order-1 lg:order-2">
211
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
212
+
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
213
+
<p class="text-gray-700 dark:text-gray-300">
214
+
The silhouette can be used where a subtle brand presence is needed,
215
+
or as a background element. Works on any background color with proper contrast.
216
+
For example, we use this as the site's favicon.
217
+
</p>
218
+
</div>
219
+
</section>
220
+
221
+
</div>
222
+
</main>
223
+
</div>
224
+
{{ end }}
+1
-1
appview/pages/templates/user/fragments/followCard.html
+1
-1
appview/pages/templates/user/fragments/followCard.html
···
1
1
{{ define "user/fragments/followCard" }}
2
2
{{ $userIdent := resolve .UserDid }}
3
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
3
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
+13
-9
appview/db/email.go
+13
-9
appview/db/email.go
···
71
71
return did, nil
72
72
}
73
73
74
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
75
-
if len(ems) == 0 {
74
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(emails) == 0 {
76
76
return make(map[string]string), nil
77
77
}
78
78
···
81
81
verifiedFilter = 1
82
82
}
83
83
84
+
assoc := make(map[string]string)
85
+
84
86
// Create placeholders for the IN clause
85
-
placeholders := make([]string, len(ems))
86
-
args := make([]any, len(ems)+1)
87
+
placeholders := make([]string, 0, len(emails))
88
+
args := make([]any, 1, len(emails)+1)
87
89
88
90
args[0] = verifiedFilter
89
-
for i, em := range ems {
90
-
placeholders[i] = "?"
91
-
args[i+1] = em
91
+
for _, email := range emails {
92
+
if strings.HasPrefix(email, "did:") {
93
+
assoc[email] = email
94
+
continue
95
+
}
96
+
placeholders = append(placeholders, "?")
97
+
args = append(args, email)
92
98
}
93
99
94
100
query := `
···
105
111
}
106
112
defer rows.Close()
107
113
108
-
assoc := make(map[string]string)
109
-
110
114
for rows.Next() {
111
115
var email, did string
112
116
if err := rows.Scan(&email, &did); err != nil {
+8
-48
appview/notify/db/db.go
+8
-48
appview/notify/db/db.go
···
30
30
31
31
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
32
32
var err error
33
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt)))
33
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
34
34
if err != nil {
35
35
log.Printf("NewStar: failed to get repos: %v", err)
36
36
return
37
37
}
38
-
if len(repos) == 0 {
39
-
log.Printf("NewStar: no repo found for %s", star.RepoAt)
40
-
return
41
-
}
42
-
repo := repos[0]
43
38
44
39
// don't notify yourself
45
40
if repo.Did == star.StarredByDid {
···
76
71
}
77
72
78
73
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
79
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
74
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
80
75
if err != nil {
81
76
log.Printf("NewIssue: failed to get repos: %v", err)
82
77
return
83
78
}
84
-
if len(repos) == 0 {
85
-
log.Printf("NewIssue: no repo found for %s", issue.RepoAt)
86
-
return
87
-
}
88
-
repo := repos[0]
89
79
90
80
if repo.Did == issue.Did {
91
81
return
···
129
119
}
130
120
issue := issues[0]
131
121
132
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
122
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
133
123
if err != nil {
134
124
log.Printf("NewIssueComment: failed to get repos: %v", err)
135
125
return
136
126
}
137
-
if len(repos) == 0 {
138
-
log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt)
139
-
return
140
-
}
141
-
repo := repos[0]
142
127
143
128
recipients := make(map[string]bool)
144
129
···
211
196
}
212
197
213
198
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
214
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
199
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
215
200
if err != nil {
216
201
log.Printf("NewPull: failed to get repos: %v", err)
217
202
return
218
203
}
219
-
if len(repos) == 0 {
220
-
log.Printf("NewPull: no repo found for %s", pull.RepoAt)
221
-
return
222
-
}
223
-
repo := repos[0]
224
204
225
205
if repo.Did == pull.OwnerDid {
226
206
return
···
266
246
}
267
247
pull := pulls[0]
268
248
269
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt))
249
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
270
250
if err != nil {
271
251
log.Printf("NewPullComment: failed to get repos: %v", err)
272
252
return
273
253
}
274
-
if len(repos) == 0 {
275
-
log.Printf("NewPullComment: no repo found for %s", comment.RepoAt)
276
-
return
277
-
}
278
-
repo := repos[0]
279
254
280
255
recipients := make(map[string]bool)
281
256
···
335
310
336
311
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
337
312
// Get repo details
338
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt)))
313
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
339
314
if err != nil {
340
315
log.Printf("NewIssueClosed: failed to get repos: %v", err)
341
316
return
342
317
}
343
-
if len(repos) == 0 {
344
-
log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt)
345
-
return
346
-
}
347
-
repo := repos[0]
348
318
349
319
// Don't notify yourself
350
320
if repo.Did == issue.Did {
···
380
350
381
351
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
382
352
// Get repo details
383
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
353
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
384
354
if err != nil {
385
355
log.Printf("NewPullMerged: failed to get repos: %v", err)
386
356
return
387
357
}
388
-
if len(repos) == 0 {
389
-
log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt)
390
-
return
391
-
}
392
-
repo := repos[0]
393
358
394
359
// Don't notify yourself
395
360
if repo.Did == pull.OwnerDid {
···
425
390
426
391
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
427
392
// Get repo details
428
-
repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt)))
393
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
429
394
if err != nil {
430
395
log.Printf("NewPullClosed: failed to get repos: %v", err)
431
396
return
432
397
}
433
-
if len(repos) == 0 {
434
-
log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt)
435
-
return
436
-
}
437
-
repo := repos[0]
438
398
439
399
// Don't notify yourself
440
400
if repo.Did == pull.OwnerDid {
+18
-13
appview/db/notifications.go
+18
-13
appview/db/notifications.go
···
3
3
import (
4
4
"context"
5
5
"database/sql"
6
+
"errors"
6
7
"fmt"
8
+
"strings"
7
9
"time"
8
10
9
11
"tangled.org/core/appview/models"
···
248
250
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
249
251
}
250
252
251
-
func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) {
252
-
recipientFilter := FilterEq("recipient_did", userDID)
253
-
readFilter := FilterEq("read", 0)
253
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
254
+
var conditions []string
255
+
var args []any
256
+
for _, filter := range filters {
257
+
conditions = append(conditions, filter.Condition())
258
+
args = append(args, filter.Arg()...)
259
+
}
254
260
255
-
query := fmt.Sprintf(`
256
-
SELECT COUNT(*)
257
-
FROM notifications
258
-
WHERE %s AND %s
259
-
`, recipientFilter.Condition(), readFilter.Condition())
261
+
whereClause := ""
262
+
if conditions != nil {
263
+
whereClause = " where " + strings.Join(conditions, " and ")
264
+
}
260
265
261
-
args := append(recipientFilter.Arg(), readFilter.Arg()...)
266
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
267
+
var count int64
268
+
err := e.QueryRow(query, args...).Scan(&count)
262
269
263
-
var count int
264
-
err := d.DB.QueryRowContext(ctx, query, args...).Scan(&count)
265
-
if err != nil {
266
-
return 0, fmt.Errorf("failed to get unread count: %w", err)
270
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
271
+
return 0, err
267
272
}
268
273
269
274
return count, nil
+48
-15
appview/pages/templates/notifications/list.html
+48
-15
appview/pages/templates/notifications/list.html
···
11
11
</div>
12
12
</div>
13
13
14
-
{{if .Notifications}}
15
-
<div class="flex flex-col gap-2" id="notifications-list">
16
-
{{range .Notifications}}
17
-
{{template "notifications/fragments/item" .}}
18
-
{{end}}
19
-
</div>
14
+
{{if .Notifications}}
15
+
<div class="flex flex-col gap-2" id="notifications-list">
16
+
{{range .Notifications}}
17
+
{{template "notifications/fragments/item" .}}
18
+
{{end}}
19
+
</div>
20
20
21
-
{{else}}
22
-
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
23
-
<div class="text-center py-12">
24
-
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
25
-
{{ i "bell-off" "w-16 h-16" }}
26
-
</div>
27
-
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
28
-
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
21
+
{{else}}
22
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
23
+
<div class="text-center py-12">
24
+
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
25
+
{{ i "bell-off" "w-16 h-16" }}
29
26
</div>
27
+
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
28
+
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
30
29
</div>
31
-
{{end}}
30
+
</div>
31
+
{{end}}
32
+
33
+
{{ template "pagination" . }}
34
+
{{ end }}
35
+
36
+
{{ define "pagination" }}
37
+
<div class="flex justify-end mt-4 gap-2">
38
+
{{ if gt .Page.Offset 0 }}
39
+
{{ $prev := .Page.Previous }}
40
+
<a
41
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
42
+
hx-boost="true"
43
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
44
+
>
45
+
{{ i "chevron-left" "w-4 h-4" }}
46
+
previous
47
+
</a>
48
+
{{ else }}
49
+
<div></div>
50
+
{{ end }}
51
+
52
+
{{ $next := .Page.Next }}
53
+
{{ if lt $next.Offset .Total }}
54
+
{{ $next := .Page.Next }}
55
+
<a
56
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
57
+
hx-boost="true"
58
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
59
+
>
60
+
next
61
+
{{ i "chevron-right" "w-4 h-4" }}
62
+
</a>
63
+
{{ end }}
64
+
</div>
32
65
{{ end }}
+1
-1
appview/pagination/page.go
+1
-1
appview/pagination/page.go
+27
-208
appview/pages/templates/notifications/fragments/item.html
+27
-208
appview/pages/templates/notifications/fragments/item.html
···
1
1
{{define "notifications/fragments/item"}}
2
-
<div
3
-
class="
4
-
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
5
-
{{if not .Read}}bg-blue-50 dark:bg-blue-900/20 border border-blue-500 dark:border-sky-800{{end}}
6
-
flex gap-2 items-center
7
-
"
8
-
>
2
+
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
3
+
<div
4
+
class="
5
+
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
6
+
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
7
+
flex gap-2 items-center
8
+
">
9
+
{{ template "notificationIcon" . }}
10
+
<div class="flex-1 w-full flex flex-col gap-1">
11
+
<span>{{ template "notificationHeader" . }}</span>
12
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
13
+
</div>
9
14
10
-
{{ template "notificationIcon" . }}
11
-
<div class="flex-1 w-full flex flex-col gap-1">
12
-
<span>{{ template "notificationHeader" . }}</span>
13
-
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
14
15
</div>
15
-
16
-
</div>
16
+
</a>
17
17
{{end}}
18
18
19
19
{{ define "notificationIcon" }}
···
64
64
{{ end }}
65
65
{{ end }}
66
66
67
-
{{define "issueNotification"}}
68
-
{{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
69
-
<a
70
-
href="{{$url}}"
71
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
72
-
>
73
-
<div class="flex items-center justify-between">
74
-
<div class="min-w-0 flex-1">
75
-
<!-- First line: icon + actor action -->
76
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
77
-
{{if eq .Type "issue_created"}}
78
-
<span class="text-green-600 dark:text-green-500">
79
-
{{ i "circle-dot" "w-4 h-4" }}
80
-
</span>
81
-
{{else if eq .Type "issue_commented"}}
82
-
<span class="text-gray-500 dark:text-gray-400">
83
-
{{ i "message-circle" "w-4 h-4" }}
84
-
</span>
85
-
{{else if eq .Type "issue_closed"}}
86
-
<span class="text-gray-500 dark:text-gray-400">
87
-
{{ i "ban" "w-4 h-4" }}
88
-
</span>
89
-
{{end}}
90
-
{{template "user/fragments/picHandle" .ActorDid}}
91
-
{{if eq .Type "issue_created"}}
92
-
<span class="text-gray-500 dark:text-gray-400">opened issue</span>
93
-
{{else if eq .Type "issue_commented"}}
94
-
<span class="text-gray-500 dark:text-gray-400">commented on issue</span>
95
-
{{else if eq .Type "issue_closed"}}
96
-
<span class="text-gray-500 dark:text-gray-400">closed issue</span>
97
-
{{end}}
98
-
{{if not .Read}}
99
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
100
-
{{end}}
101
-
</div>
102
-
103
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
104
-
<span class="text-gray-500 dark:text-gray-400">#{{.Issue.IssueId}}</span>
105
-
<span class="text-gray-900 dark:text-white truncate">{{.Issue.Title}}</span>
106
-
<span>on</span>
107
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
108
-
</div>
109
-
</div>
110
-
111
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
112
-
{{ template "repo/fragments/time" .Created }}
113
-
</div>
114
-
</div>
115
-
</a>
116
-
{{end}}
117
-
118
-
{{define "pullNotification"}}
119
-
{{$url := printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
120
-
<a
121
-
href="{{$url}}"
122
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
123
-
>
124
-
<div class="flex items-center justify-between">
125
-
<div class="min-w-0 flex-1">
126
-
<div class="flex items-center gap-2 text-gray-900 dark:text-white">
127
-
{{if eq .Type "pull_created"}}
128
-
<span class="text-green-600 dark:text-green-500">
129
-
{{ i "git-pull-request-create" "w-4 h-4" }}
130
-
</span>
131
-
{{else if eq .Type "pull_commented"}}
132
-
<span class="text-gray-500 dark:text-gray-400">
133
-
{{ i "message-circle" "w-4 h-4" }}
134
-
</span>
135
-
{{else if eq .Type "pull_merged"}}
136
-
<span class="text-purple-600 dark:text-purple-500">
137
-
{{ i "git-merge" "w-4 h-4" }}
138
-
</span>
139
-
{{else if eq .Type "pull_closed"}}
140
-
<span class="text-red-600 dark:text-red-500">
141
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
142
-
</span>
143
-
{{end}}
144
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
145
-
{{if eq .Type "pull_created"}}
146
-
<span class="text-gray-500 dark:text-gray-400">opened pull request</span>
147
-
{{else if eq .Type "pull_commented"}}
148
-
<span class="text-gray-500 dark:text-gray-400">commented on pull request</span>
149
-
{{else if eq .Type "pull_merged"}}
150
-
<span class="text-gray-500 dark:text-gray-400">merged pull request</span>
151
-
{{else if eq .Type "pull_closed"}}
152
-
<span class="text-gray-500 dark:text-gray-400">closed pull request</span>
153
-
{{end}}
154
-
{{if not .Read}}
155
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
156
-
{{end}}
157
-
</div>
158
-
159
-
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5 ml-6 flex items-center gap-1">
160
-
<span class="text-gray-500 dark:text-gray-400">#{{.Pull.PullId}}</span>
161
-
<span class="text-gray-900 dark:text-white truncate">{{.Pull.Title}}</span>
162
-
<span>on</span>
163
-
<span class="font-medium text-gray-900 dark:text-white">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
164
-
</div>
165
-
</div>
166
-
167
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
168
-
{{ template "repo/fragments/time" .Created }}
169
-
</div>
170
-
</div>
171
-
</a>
172
-
{{end}}
173
-
174
-
{{define "repoNotification"}}
175
-
{{$url := printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
176
-
<a
177
-
href="{{$url}}"
178
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
179
-
>
180
-
<div class="flex items-center justify-between">
181
-
<div class="flex items-center gap-2 min-w-0 flex-1">
182
-
<span class="text-yellow-500 dark:text-yellow-400">
183
-
{{ i "star" "w-4 h-4" }}
184
-
</span>
185
-
186
-
<div class="min-w-0 flex-1">
187
-
<!-- Single line for stars: actor action subject -->
188
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
189
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
190
-
<span class="text-gray-500 dark:text-gray-400">starred</span>
191
-
<span class="font-medium">{{resolve .Repo.Did}}/{{.Repo.Name}}</span>
192
-
{{if not .Read}}
193
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
194
-
{{end}}
195
-
</div>
196
-
</div>
197
-
</div>
198
-
199
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
200
-
{{ template "repo/fragments/time" .Created }}
201
-
</div>
202
-
</div>
203
-
</a>
204
-
{{end}}
205
-
206
-
{{define "followNotification"}}
207
-
{{$url := printf "/%s" (resolve .ActorDid)}}
208
-
<a
209
-
href="{{$url}}"
210
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
211
-
>
212
-
<div class="flex items-center justify-between">
213
-
<div class="flex items-center gap-2 min-w-0 flex-1">
214
-
<span class="text-blue-600 dark:text-blue-400">
215
-
{{ i "user-plus" "w-4 h-4" }}
216
-
</span>
217
-
218
-
<div class="min-w-0 flex-1">
219
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
220
-
{{template "user/fragments/picHandle" (resolve .ActorDid)}}
221
-
<span class="text-gray-500 dark:text-gray-400">followed you</span>
222
-
{{if not .Read}}
223
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
224
-
{{end}}
225
-
</div>
226
-
</div>
227
-
</div>
228
-
229
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
230
-
{{ template "repo/fragments/time" .Created }}
231
-
</div>
232
-
</div>
233
-
</a>
234
-
{{end}}
235
-
236
-
{{define "genericNotification"}}
237
-
<a
238
-
href="#"
239
-
class="block no-underline hover:no-underline text-inherit -m-3 p-3"
240
-
>
241
-
<div class="flex items-center justify-between">
242
-
<div class="flex items-center gap-2 min-w-0 flex-1">
243
-
<span class="{{if not .Read}}text-blue-600 dark:text-blue-400{{else}}text-gray-500 dark:text-gray-400{{end}}">
244
-
{{ i "bell" "w-4 h-4" }}
245
-
</span>
246
-
247
-
<div class="min-w-0 flex-1">
248
-
<div class="flex items-center gap-1 text-gray-900 dark:text-white">
249
-
<span>New notification</span>
250
-
{{if not .Read}}
251
-
<div class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-1"></div>
252
-
{{end}}
253
-
</div>
254
-
</div>
255
-
</div>
67
+
{{ define "notificationUrl" }}
68
+
{{ $url := "" }}
69
+
{{ if eq .Type "repo_starred" }}
70
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
71
+
{{ else if .Issue }}
72
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
73
+
{{ else if .Pull }}
74
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
75
+
{{ else if eq .Type "followed" }}
76
+
{{$url = printf "/%s" (resolve .ActorDid)}}
77
+
{{ else }}
78
+
{{ end }}
256
79
257
-
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
258
-
{{ template "repo/fragments/time" .Created }}
259
-
</div>
260
-
</div>
261
-
</a>
262
-
{{end}}
80
+
{{ $url }}
81
+
{{ end }}
+1
appview/pages/templates/user/signup.html
+1
appview/pages/templates/user/signup.html
···
8
8
<meta property="og:url" content="https://tangled.org/signup" />
9
9
<meta property="og:description" content="sign up for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>sign up · tangled</title>
13
14
+5
appview/models/repo.go
+5
appview/models/repo.go
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
1
+
{{ define "repo/issues/fragments/globalIssueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2 mb-3">
6
+
<div class="flex items-center gap-3 mb-2">
7
+
<a
8
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}"
9
+
class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm"
10
+
>
11
+
{{ resolve .Repo.Did }}/{{ .Repo.Name }}
12
+
</a>
13
+
</div>
14
+
<a
15
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}"
16
+
class="no-underline hover:underline"
17
+
>
18
+
{{ .Title | description }}
19
+
<span class="text-gray-500">#{{ .IssueId }}</span>
20
+
</a>
21
+
</div>
22
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
23
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
24
+
{{ $icon := "ban" }}
25
+
{{ $state := "closed" }}
26
+
{{ if .Open }}
27
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
28
+
{{ $icon = "circle-dot" }}
29
+
{{ $state = "open" }}
30
+
{{ end }}
31
+
32
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
33
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
34
+
<span class="text-white dark:text-white">{{ $state }}</span>
35
+
</span>
36
+
37
+
<span class="ml-1">
38
+
{{ template "user/fragments/picHandleLink" .Did }}
39
+
</span>
40
+
41
+
<span class="before:content-['·']">
42
+
{{ template "repo/fragments/time" .Created }}
43
+
</span>
44
+
45
+
<span class="before:content-['·']">
46
+
{{ $s := "s" }}
47
+
{{ if eq (len .Comments) 1 }}
48
+
{{ $s = "" }}
49
+
{{ end }}
50
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
51
+
</span>
52
+
53
+
{{ $state := .Labels }}
54
+
{{ range $k, $d := $.LabelDefs }}
55
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
56
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
57
+
{{ end }}
58
+
{{ end }}
59
+
</div>
60
+
</div>
61
+
{{ end }}
62
+
</div>
63
+
{{ end }}
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
···
1
+
{{ define "repo/issues/fragments/issueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2">
6
+
<a
7
+
href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}"
8
+
class="no-underline hover:underline"
9
+
>
10
+
{{ .Title | description }}
11
+
<span class="text-gray-500">#{{ .IssueId }}</span>
12
+
</a>
13
+
</div>
14
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
15
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
16
+
{{ $icon := "ban" }}
17
+
{{ $state := "closed" }}
18
+
{{ if .Open }}
19
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
20
+
{{ $icon = "circle-dot" }}
21
+
{{ $state = "open" }}
22
+
{{ end }}
23
+
24
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
25
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
26
+
<span class="text-white dark:text-white">{{ $state }}</span>
27
+
</span>
28
+
29
+
<span class="ml-1">
30
+
{{ template "user/fragments/picHandleLink" .Did }}
31
+
</span>
32
+
33
+
<span class="before:content-['·']">
34
+
{{ template "repo/fragments/time" .Created }}
35
+
</span>
36
+
37
+
<span class="before:content-['·']">
38
+
{{ $s := "s" }}
39
+
{{ if eq (len .Comments) 1 }}
40
+
{{ $s = "" }}
41
+
{{ end }}
42
+
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
43
+
</span>
44
+
45
+
{{ $state := .Labels }}
46
+
{{ range $k, $d := $.LabelDefs }}
47
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
48
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
49
+
{{ end }}
50
+
{{ end }}
51
+
</div>
52
+
</div>
53
+
{{ end }}
54
+
</div>
55
+
{{ end }}
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/timeline/timeline.html
+2
-5
appview/pages/templates/timeline/fragments/goodfirstissues.html
+2
-5
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
6
6
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
7
7
<p>
8
8
Make your first contribution to an open-source project this October.
9
-
</p>
10
-
<p>
11
-
<em>good-first-issue</em> is a collection of issues on open-source projects
12
-
that are easy ways for new contributors to give back to the projects
13
-
they love.
9
+
<em>good-first-issue</em> helps new contributors find easy ways to
10
+
start contributing to open-source projects.
14
11
</p>
15
12
<span class="flex items-center gap-2 text-purple-500 dark:text-purple-400">
16
13
Browse issues {{ i "arrow-right" "size-4" }}
+3
-4
appview/pages/templates/goodfirstissues/index.html
+3
-4
appview/pages/templates/goodfirstissues/index.html
···
37
37
{{ else }}
38
38
{{ range .RepoGroups }}
39
39
<div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800">
40
-
<div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between">
40
+
<div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap">
41
41
<div class="font-medium dark:text-white flex items-center justify-between">
42
42
<div class="flex items-center min-w-0 flex-1 mr-2">
43
43
{{ if .Repo.Source }}
···
103
103
<div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400">
104
104
<span>
105
105
<div class="inline-flex items-center gap-1">
106
-
{{ i "message-square" "w-3 h-3 md:hidden" }}
106
+
{{ i "message-square" "w-3 h-3" }}
107
107
{{ len .Comments }}
108
-
<span class="hidden md:inline">comment{{ if ne (len .Comments) 1 }}s{{ end }}</span>
109
108
</div>
110
109
</span>
111
110
<span class="before:content-['·'] before:select-none"></span>
112
111
<span class="text-sm">
113
-
{{ template "repo/fragments/time" .Created }}
112
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
114
113
</span>
115
114
<div class="hidden md:inline-flex md:gap-1">
116
115
{{ $labelState := .Labels }}
+13
-1
appview/state/gfi.go
+13
-1
appview/state/gfi.go
···
47
47
repoUris = append(repoUris, rl.RepoAt.String())
48
48
}
49
49
50
-
allIssues, err := db.GetIssues(
50
+
allIssues, err := db.GetIssuesPaginated(
51
51
s.db,
52
+
pagination.Page{
53
+
Limit: 500,
54
+
},
52
55
db.FilterIn("repo_at", repoUris),
53
56
db.FilterEq("open", 1),
54
57
)
···
83
86
}
84
87
85
88
sort.Slice(sortedGroups, func(i, j int) bool {
89
+
iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid
90
+
jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid
91
+
92
+
// If one is tangled and the other isn't, non-tangled comes first
93
+
if iIsTangled != jIsTangled {
94
+
return jIsTangled // true if j is tangled (i should come first)
95
+
}
96
+
97
+
// Both tangled or both not tangled: sort by name
86
98
return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name
87
99
})
88
100
+2
-1
appview/oauth/consts.go
+2
-1
appview/oauth/consts.go
-538
appview/oauth/handler/handler.go
-538
appview/oauth/handler/handler.go
···
1
-
package oauth
2
-
3
-
import (
4
-
"bytes"
5
-
"context"
6
-
"encoding/json"
7
-
"fmt"
8
-
"log"
9
-
"net/http"
10
-
"net/url"
11
-
"slices"
12
-
"strings"
13
-
"time"
14
-
15
-
"github.com/go-chi/chi/v5"
16
-
"github.com/gorilla/sessions"
17
-
"github.com/lestrrat-go/jwx/v2/jwk"
18
-
"github.com/posthog/posthog-go"
19
-
"tangled.org/anirudh.fi/atproto-oauth/helpers"
20
-
tangled "tangled.org/core/api/tangled"
21
-
sessioncache "tangled.org/core/appview/cache/session"
22
-
"tangled.org/core/appview/config"
23
-
"tangled.org/core/appview/db"
24
-
"tangled.org/core/appview/middleware"
25
-
"tangled.org/core/appview/oauth"
26
-
"tangled.org/core/appview/oauth/client"
27
-
"tangled.org/core/appview/pages"
28
-
"tangled.org/core/consts"
29
-
"tangled.org/core/idresolver"
30
-
"tangled.org/core/rbac"
31
-
"tangled.org/core/tid"
32
-
)
33
-
34
-
const (
35
-
oauthScope = "atproto transition:generic"
36
-
)
37
-
38
-
type OAuthHandler struct {
39
-
config *config.Config
40
-
pages *pages.Pages
41
-
idResolver *idresolver.Resolver
42
-
sess *sessioncache.SessionStore
43
-
db *db.DB
44
-
store *sessions.CookieStore
45
-
oauth *oauth.OAuth
46
-
enforcer *rbac.Enforcer
47
-
posthog posthog.Client
48
-
}
49
-
50
-
func New(
51
-
config *config.Config,
52
-
pages *pages.Pages,
53
-
idResolver *idresolver.Resolver,
54
-
db *db.DB,
55
-
sess *sessioncache.SessionStore,
56
-
store *sessions.CookieStore,
57
-
oauth *oauth.OAuth,
58
-
enforcer *rbac.Enforcer,
59
-
posthog posthog.Client,
60
-
) *OAuthHandler {
61
-
return &OAuthHandler{
62
-
config: config,
63
-
pages: pages,
64
-
idResolver: idResolver,
65
-
db: db,
66
-
sess: sess,
67
-
store: store,
68
-
oauth: oauth,
69
-
enforcer: enforcer,
70
-
posthog: posthog,
71
-
}
72
-
}
73
-
74
-
func (o *OAuthHandler) Router() http.Handler {
75
-
r := chi.NewRouter()
76
-
77
-
r.Get("/login", o.login)
78
-
r.Post("/login", o.login)
79
-
80
-
r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout)
81
-
82
-
r.Get("/oauth/client-metadata.json", o.clientMetadata)
83
-
r.Get("/oauth/jwks.json", o.jwks)
84
-
r.Get("/oauth/callback", o.callback)
85
-
return r
86
-
}
87
-
88
-
func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) {
89
-
w.Header().Set("Content-Type", "application/json")
90
-
w.WriteHeader(http.StatusOK)
91
-
json.NewEncoder(w).Encode(o.oauth.ClientMetadata())
92
-
}
93
-
94
-
func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) {
95
-
jwks := o.config.OAuth.Jwks
96
-
pubKey, err := pubKeyFromJwk(jwks)
97
-
if err != nil {
98
-
log.Printf("error parsing public key: %v", err)
99
-
http.Error(w, err.Error(), http.StatusInternalServerError)
100
-
return
101
-
}
102
-
103
-
response := helpers.CreateJwksResponseObject(pubKey)
104
-
105
-
w.Header().Set("Content-Type", "application/json")
106
-
w.WriteHeader(http.StatusOK)
107
-
json.NewEncoder(w).Encode(response)
108
-
}
109
-
110
-
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
111
-
switch r.Method {
112
-
case http.MethodGet:
113
-
returnURL := r.URL.Query().Get("return_url")
114
-
o.pages.Login(w, pages.LoginParams{
115
-
ReturnUrl: returnURL,
116
-
})
117
-
case http.MethodPost:
118
-
handle := r.FormValue("handle")
119
-
120
-
// when users copy their handle from bsky.app, it tends to have these characters around it:
121
-
//
122
-
// @nelind.dk:
123
-
// \u202a ensures that the handle is always rendered left to right and
124
-
// \u202c reverts that so the rest of the page renders however it should
125
-
handle = strings.TrimPrefix(handle, "\u202a")
126
-
handle = strings.TrimSuffix(handle, "\u202c")
127
-
128
-
// `@` is harmless
129
-
handle = strings.TrimPrefix(handle, "@")
130
-
131
-
// basic handle validation
132
-
if !strings.Contains(handle, ".") {
133
-
log.Println("invalid handle format", "raw", handle)
134
-
o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle))
135
-
return
136
-
}
137
-
138
-
resolved, err := o.idResolver.ResolveIdent(r.Context(), handle)
139
-
if err != nil {
140
-
log.Println("failed to resolve handle:", err)
141
-
o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
142
-
return
143
-
}
144
-
self := o.oauth.ClientMetadata()
145
-
oauthClient, err := client.NewClient(
146
-
self.ClientID,
147
-
o.config.OAuth.Jwks,
148
-
self.RedirectURIs[0],
149
-
)
150
-
151
-
if err != nil {
152
-
log.Println("failed to create oauth client:", err)
153
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
154
-
return
155
-
}
156
-
157
-
authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint())
158
-
if err != nil {
159
-
log.Println("failed to resolve auth server:", err)
160
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
161
-
return
162
-
}
163
-
164
-
authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer)
165
-
if err != nil {
166
-
log.Println("failed to fetch auth server metadata:", err)
167
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
168
-
return
169
-
}
170
-
171
-
dpopKey, err := helpers.GenerateKey(nil)
172
-
if err != nil {
173
-
log.Println("failed to generate dpop key:", err)
174
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
175
-
return
176
-
}
177
-
178
-
dpopKeyJson, err := json.Marshal(dpopKey)
179
-
if err != nil {
180
-
log.Println("failed to marshal dpop key:", err)
181
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
182
-
return
183
-
}
184
-
185
-
parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey)
186
-
if err != nil {
187
-
log.Println("failed to send par auth request:", err)
188
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
189
-
return
190
-
}
191
-
192
-
err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{
193
-
Did: resolved.DID.String(),
194
-
PdsUrl: resolved.PDSEndpoint(),
195
-
Handle: handle,
196
-
AuthserverIss: authMeta.Issuer,
197
-
PkceVerifier: parResp.PkceVerifier,
198
-
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
199
-
DpopPrivateJwk: string(dpopKeyJson),
200
-
State: parResp.State,
201
-
ReturnUrl: r.FormValue("return_url"),
202
-
})
203
-
if err != nil {
204
-
log.Println("failed to save oauth request:", err)
205
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
206
-
return
207
-
}
208
-
209
-
u, _ := url.Parse(authMeta.AuthorizationEndpoint)
210
-
query := url.Values{}
211
-
query.Add("client_id", self.ClientID)
212
-
query.Add("request_uri", parResp.RequestUri)
213
-
u.RawQuery = query.Encode()
214
-
o.pages.HxRedirect(w, u.String())
215
-
}
216
-
}
217
-
218
-
func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) {
219
-
state := r.FormValue("state")
220
-
221
-
oauthRequest, err := o.sess.GetRequestByState(r.Context(), state)
222
-
if err != nil {
223
-
log.Println("failed to get oauth request:", err)
224
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
225
-
return
226
-
}
227
-
228
-
defer func() {
229
-
err := o.sess.DeleteRequestByState(r.Context(), state)
230
-
if err != nil {
231
-
log.Println("failed to delete oauth request for state:", state, err)
232
-
}
233
-
}()
234
-
235
-
error := r.FormValue("error")
236
-
errorDescription := r.FormValue("error_description")
237
-
if error != "" || errorDescription != "" {
238
-
log.Printf("error: %s, %s", error, errorDescription)
239
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
240
-
return
241
-
}
242
-
243
-
code := r.FormValue("code")
244
-
if code == "" {
245
-
log.Println("missing code for state: ", state)
246
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
247
-
return
248
-
}
249
-
250
-
iss := r.FormValue("iss")
251
-
if iss == "" {
252
-
log.Println("missing iss for state: ", state)
253
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
254
-
return
255
-
}
256
-
257
-
if iss != oauthRequest.AuthserverIss {
258
-
log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state)
259
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
260
-
return
261
-
}
262
-
263
-
self := o.oauth.ClientMetadata()
264
-
265
-
oauthClient, err := client.NewClient(
266
-
self.ClientID,
267
-
o.config.OAuth.Jwks,
268
-
self.RedirectURIs[0],
269
-
)
270
-
271
-
if err != nil {
272
-
log.Println("failed to create oauth client:", err)
273
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
274
-
return
275
-
}
276
-
277
-
jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
278
-
if err != nil {
279
-
log.Println("failed to parse jwk:", err)
280
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
281
-
return
282
-
}
283
-
284
-
tokenResp, err := oauthClient.InitialTokenRequest(
285
-
r.Context(),
286
-
code,
287
-
oauthRequest.AuthserverIss,
288
-
oauthRequest.PkceVerifier,
289
-
oauthRequest.DpopAuthserverNonce,
290
-
jwk,
291
-
)
292
-
if err != nil {
293
-
log.Println("failed to get token:", err)
294
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
295
-
return
296
-
}
297
-
298
-
if tokenResp.Scope != oauthScope {
299
-
log.Println("scope doesn't match:", tokenResp.Scope)
300
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
301
-
return
302
-
}
303
-
304
-
err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp)
305
-
if err != nil {
306
-
log.Println("failed to save session:", err)
307
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
308
-
return
309
-
}
310
-
311
-
log.Println("session saved successfully")
312
-
go o.addToDefaultKnot(oauthRequest.Did)
313
-
go o.addToDefaultSpindle(oauthRequest.Did)
314
-
315
-
if !o.config.Core.Dev {
316
-
err = o.posthog.Enqueue(posthog.Capture{
317
-
DistinctId: oauthRequest.Did,
318
-
Event: "signin",
319
-
})
320
-
if err != nil {
321
-
log.Println("failed to enqueue posthog event:", err)
322
-
}
323
-
}
324
-
325
-
returnUrl := oauthRequest.ReturnUrl
326
-
if returnUrl == "" {
327
-
returnUrl = "/"
328
-
}
329
-
330
-
http.Redirect(w, r, returnUrl, http.StatusFound)
331
-
}
332
-
333
-
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
334
-
err := o.oauth.ClearSession(r, w)
335
-
if err != nil {
336
-
log.Println("failed to clear session:", err)
337
-
http.Redirect(w, r, "/", http.StatusFound)
338
-
return
339
-
}
340
-
341
-
log.Println("session cleared successfully")
342
-
o.pages.HxRedirect(w, "/login")
343
-
}
344
-
345
-
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
346
-
k, err := helpers.ParseJWKFromBytes([]byte(jwks))
347
-
if err != nil {
348
-
return nil, err
349
-
}
350
-
pubKey, err := k.PublicKey()
351
-
if err != nil {
352
-
return nil, err
353
-
}
354
-
return pubKey, nil
355
-
}
356
-
357
-
func (o *OAuthHandler) addToDefaultSpindle(did string) {
358
-
// use the tangled.sh app password to get an accessJwt
359
-
// and create an sh.tangled.spindle.member record with that
360
-
spindleMembers, err := db.GetSpindleMembers(
361
-
o.db,
362
-
db.FilterEq("instance", "spindle.tangled.sh"),
363
-
db.FilterEq("subject", did),
364
-
)
365
-
if err != nil {
366
-
log.Printf("failed to get spindle members for did %s: %v", did, err)
367
-
return
368
-
}
369
-
370
-
if len(spindleMembers) != 0 {
371
-
log.Printf("did %s is already a member of the default spindle", did)
372
-
return
373
-
}
374
-
375
-
log.Printf("adding %s to default spindle", did)
376
-
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid)
377
-
if err != nil {
378
-
log.Printf("failed to create session: %s", err)
379
-
return
380
-
}
381
-
382
-
record := tangled.SpindleMember{
383
-
LexiconTypeID: "sh.tangled.spindle.member",
384
-
Subject: did,
385
-
Instance: consts.DefaultSpindle,
386
-
CreatedAt: time.Now().Format(time.RFC3339),
387
-
}
388
-
389
-
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
390
-
log.Printf("failed to add member to default spindle: %s", err)
391
-
return
392
-
}
393
-
394
-
log.Printf("successfully added %s to default spindle", did)
395
-
}
396
-
397
-
func (o *OAuthHandler) addToDefaultKnot(did string) {
398
-
// use the tangled.sh app password to get an accessJwt
399
-
// and create an sh.tangled.spindle.member record with that
400
-
401
-
allKnots, err := o.enforcer.GetKnotsForUser(did)
402
-
if err != nil {
403
-
log.Printf("failed to get knot members for did %s: %v", did, err)
404
-
return
405
-
}
406
-
407
-
if slices.Contains(allKnots, consts.DefaultKnot) {
408
-
log.Printf("did %s is already a member of the default knot", did)
409
-
return
410
-
}
411
-
412
-
log.Printf("adding %s to default knot", did)
413
-
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid)
414
-
if err != nil {
415
-
log.Printf("failed to create session: %s", err)
416
-
return
417
-
}
418
-
419
-
record := tangled.KnotMember{
420
-
LexiconTypeID: "sh.tangled.knot.member",
421
-
Subject: did,
422
-
Domain: consts.DefaultKnot,
423
-
CreatedAt: time.Now().Format(time.RFC3339),
424
-
}
425
-
426
-
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
427
-
log.Printf("failed to add member to default knot: %s", err)
428
-
return
429
-
}
430
-
431
-
if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
432
-
log.Printf("failed to set up enforcer rules: %s", err)
433
-
return
434
-
}
435
-
436
-
log.Printf("successfully added %s to default Knot", did)
437
-
}
438
-
439
-
// create a session using apppasswords
440
-
type session struct {
441
-
AccessJwt string `json:"accessJwt"`
442
-
PdsEndpoint string
443
-
Did string
444
-
}
445
-
446
-
func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) {
447
-
if appPassword == "" {
448
-
return nil, fmt.Errorf("no app password configured, skipping member addition")
449
-
}
450
-
451
-
resolved, err := o.idResolver.ResolveIdent(context.Background(), did)
452
-
if err != nil {
453
-
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
454
-
}
455
-
456
-
pdsEndpoint := resolved.PDSEndpoint()
457
-
if pdsEndpoint == "" {
458
-
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
459
-
}
460
-
461
-
sessionPayload := map[string]string{
462
-
"identifier": did,
463
-
"password": appPassword,
464
-
}
465
-
sessionBytes, err := json.Marshal(sessionPayload)
466
-
if err != nil {
467
-
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
468
-
}
469
-
470
-
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
471
-
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
472
-
if err != nil {
473
-
return nil, fmt.Errorf("failed to create session request: %v", err)
474
-
}
475
-
sessionReq.Header.Set("Content-Type", "application/json")
476
-
477
-
client := &http.Client{Timeout: 30 * time.Second}
478
-
sessionResp, err := client.Do(sessionReq)
479
-
if err != nil {
480
-
return nil, fmt.Errorf("failed to create session: %v", err)
481
-
}
482
-
defer sessionResp.Body.Close()
483
-
484
-
if sessionResp.StatusCode != http.StatusOK {
485
-
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
486
-
}
487
-
488
-
var session session
489
-
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
490
-
return nil, fmt.Errorf("failed to decode session response: %v", err)
491
-
}
492
-
493
-
session.PdsEndpoint = pdsEndpoint
494
-
session.Did = did
495
-
496
-
return &session, nil
497
-
}
498
-
499
-
func (s *session) putRecord(record any, collection string) error {
500
-
recordBytes, err := json.Marshal(record)
501
-
if err != nil {
502
-
return fmt.Errorf("failed to marshal knot member record: %w", err)
503
-
}
504
-
505
-
payload := map[string]any{
506
-
"repo": s.Did,
507
-
"collection": collection,
508
-
"rkey": tid.TID(),
509
-
"record": json.RawMessage(recordBytes),
510
-
}
511
-
512
-
payloadBytes, err := json.Marshal(payload)
513
-
if err != nil {
514
-
return fmt.Errorf("failed to marshal request payload: %w", err)
515
-
}
516
-
517
-
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
518
-
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
519
-
if err != nil {
520
-
return fmt.Errorf("failed to create HTTP request: %w", err)
521
-
}
522
-
523
-
req.Header.Set("Content-Type", "application/json")
524
-
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
525
-
526
-
client := &http.Client{Timeout: 30 * time.Second}
527
-
resp, err := client.Do(req)
528
-
if err != nil {
529
-
return fmt.Errorf("failed to add user to default service: %w", err)
530
-
}
531
-
defer resp.Body.Close()
532
-
533
-
if resp.StatusCode != http.StatusOK {
534
-
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
535
-
}
536
-
537
-
return nil
538
-
}
+147
appview/oauth/store.go
+147
appview/oauth/store.go
···
1
+
package oauth
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"time"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"github.com/redis/go-redis/v9"
12
+
)
13
+
14
+
// redis-backed implementation of ClientAuthStore.
15
+
type RedisStore struct {
16
+
client *redis.Client
17
+
SessionTTL time.Duration
18
+
AuthRequestTTL time.Duration
19
+
}
20
+
21
+
var _ oauth.ClientAuthStore = &RedisStore{}
22
+
23
+
func NewRedisStore(redisURL string) (*RedisStore, error) {
24
+
opts, err := redis.ParseURL(redisURL)
25
+
if err != nil {
26
+
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
27
+
}
28
+
29
+
client := redis.NewClient(opts)
30
+
31
+
// test the connection
32
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
33
+
defer cancel()
34
+
35
+
if err := client.Ping(ctx).Err(); err != nil {
36
+
return nil, fmt.Errorf("failed to connect to redis: %w", err)
37
+
}
38
+
39
+
return &RedisStore{
40
+
client: client,
41
+
SessionTTL: 30 * 24 * time.Hour, // 30 days
42
+
AuthRequestTTL: 10 * time.Minute, // 10 minutes
43
+
}, nil
44
+
}
45
+
46
+
func (r *RedisStore) Close() error {
47
+
return r.client.Close()
48
+
}
49
+
50
+
func sessionKey(did syntax.DID, sessionID string) string {
51
+
return fmt.Sprintf("oauth:session:%s:%s", did, sessionID)
52
+
}
53
+
54
+
func authRequestKey(state string) string {
55
+
return fmt.Sprintf("oauth:auth_request:%s", state)
56
+
}
57
+
58
+
func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
59
+
key := sessionKey(did, sessionID)
60
+
data, err := r.client.Get(ctx, key).Bytes()
61
+
if err == redis.Nil {
62
+
return nil, fmt.Errorf("session not found: %s", did)
63
+
}
64
+
if err != nil {
65
+
return nil, fmt.Errorf("failed to get session: %w", err)
66
+
}
67
+
68
+
var sess oauth.ClientSessionData
69
+
if err := json.Unmarshal(data, &sess); err != nil {
70
+
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
71
+
}
72
+
73
+
return &sess, nil
74
+
}
75
+
76
+
func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
77
+
key := sessionKey(sess.AccountDID, sess.SessionID)
78
+
79
+
data, err := json.Marshal(sess)
80
+
if err != nil {
81
+
return fmt.Errorf("failed to marshal session: %w", err)
82
+
}
83
+
84
+
if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil {
85
+
return fmt.Errorf("failed to save session: %w", err)
86
+
}
87
+
88
+
return nil
89
+
}
90
+
91
+
func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
92
+
key := sessionKey(did, sessionID)
93
+
if err := r.client.Del(ctx, key).Err(); err != nil {
94
+
return fmt.Errorf("failed to delete session: %w", err)
95
+
}
96
+
return nil
97
+
}
98
+
99
+
func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
100
+
key := authRequestKey(state)
101
+
data, err := r.client.Get(ctx, key).Bytes()
102
+
if err == redis.Nil {
103
+
return nil, fmt.Errorf("request info not found: %s", state)
104
+
}
105
+
if err != nil {
106
+
return nil, fmt.Errorf("failed to get auth request: %w", err)
107
+
}
108
+
109
+
var req oauth.AuthRequestData
110
+
if err := json.Unmarshal(data, &req); err != nil {
111
+
return nil, fmt.Errorf("failed to unmarshal auth request: %w", err)
112
+
}
113
+
114
+
return &req, nil
115
+
}
116
+
117
+
func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
118
+
key := authRequestKey(info.State)
119
+
120
+
// check if already exists (to match MemStore behavior)
121
+
exists, err := r.client.Exists(ctx, key).Result()
122
+
if err != nil {
123
+
return fmt.Errorf("failed to check auth request existence: %w", err)
124
+
}
125
+
if exists > 0 {
126
+
return fmt.Errorf("auth request already saved for state %s", info.State)
127
+
}
128
+
129
+
data, err := json.Marshal(info)
130
+
if err != nil {
131
+
return fmt.Errorf("failed to marshal auth request: %w", err)
132
+
}
133
+
134
+
if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil {
135
+
return fmt.Errorf("failed to save auth request: %w", err)
136
+
}
137
+
138
+
return nil
139
+
}
140
+
141
+
func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
142
+
key := authRequestKey(state)
143
+
if err := r.client.Del(ctx, key).Err(); err != nil {
144
+
return fmt.Errorf("failed to delete auth request: %w", err)
145
+
}
146
+
return nil
147
+
}
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
3
3
id="pull-comment-card-{{ .RoundNumber }}"
4
4
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
5
<div class="text-sm text-gray-500 dark:text-gray-400">
6
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
6
+
{{ resolve .LoggedInUser.Did }}
7
7
</div>
8
8
<form
9
9
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+11
-10
appview/repo/artifact.go
+11
-10
appview/repo/artifact.go
···
10
10
"net/url"
11
11
"time"
12
12
13
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
-
lexutil "github.com/bluesky-social/indigo/lex/util"
15
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16
-
"github.com/dustin/go-humanize"
17
-
"github.com/go-chi/chi/v5"
18
-
"github.com/go-git/go-git/v5/plumbing"
19
-
"github.com/ipfs/go-cid"
20
13
"tangled.org/core/api/tangled"
21
14
"tangled.org/core/appview/db"
22
15
"tangled.org/core/appview/models"
···
25
18
"tangled.org/core/appview/xrpcclient"
26
19
"tangled.org/core/tid"
27
20
"tangled.org/core/types"
21
+
22
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
+
lexutil "github.com/bluesky-social/indigo/lex/util"
24
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
25
+
"github.com/dustin/go-humanize"
26
+
"github.com/go-chi/chi/v5"
27
+
"github.com/go-git/go-git/v5/plumbing"
28
+
"github.com/ipfs/go-cid"
28
29
)
29
30
30
31
// TODO: proper statuses here on early exit
···
60
61
return
61
62
}
62
63
63
-
uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file)
64
+
uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)
64
65
if err != nil {
65
66
log.Println("failed to upload blob", err)
66
67
rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
···
72
73
rkey := tid.TID()
73
74
createdAt := time.Now()
74
75
75
-
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
76
+
putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
76
77
Collection: tangled.RepoArtifactNSID,
77
78
Repo: user.Did,
78
79
Rkey: rkey,
···
249
250
return
250
251
}
251
252
252
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
253
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
253
254
Collection: tangled.RepoArtifactNSID,
254
255
Repo: user.Did,
255
256
Rkey: artifact.Rkey,
+5
-5
appview/spindles/spindles.go
+5
-5
appview/spindles/spindles.go
···
189
189
return
190
190
}
191
191
192
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
192
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance)
193
193
var exCid *string
194
194
if ex != nil {
195
195
exCid = ex.Cid
196
196
}
197
197
198
198
// re-announce by registering under same rkey
199
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
199
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
200
200
Collection: tangled.SpindleNSID,
201
201
Repo: user.Did,
202
202
Rkey: instance,
···
332
332
return
333
333
}
334
334
335
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
335
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
336
336
Collection: tangled.SpindleNSID,
337
337
Repo: user.Did,
338
338
Rkey: instance,
···
542
542
return
543
543
}
544
544
545
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
545
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
546
546
Collection: tangled.SpindleMemberNSID,
547
547
Repo: user.Did,
548
548
Rkey: rkey,
···
683
683
}
684
684
685
685
// remove from pds
686
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
686
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
687
687
Collection: tangled.SpindleMemberNSID,
688
688
Repo: user.Did,
689
689
Rkey: members[0].Rkey,
+2
-2
appview/state/follow.go
+2
-2
appview/state/follow.go
···
43
43
case http.MethodPost:
44
44
createdAt := time.Now().Format(time.RFC3339)
45
45
rkey := tid.TID()
46
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
46
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
47
47
Collection: tangled.GraphFollowNSID,
48
48
Repo: currentUser.Did,
49
49
Rkey: rkey,
···
88
88
return
89
89
}
90
90
91
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
91
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
92
92
Collection: tangled.GraphFollowNSID,
93
93
Repo: currentUser.Did,
94
94
Rkey: follow.Rkey,
+2
-2
appview/state/profile.go
+2
-2
appview/state/profile.go
···
634
634
vanityStats = append(vanityStats, string(v.Kind))
635
635
}
636
636
637
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
637
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
638
638
var cid *string
639
639
if ex != nil {
640
640
cid = ex.Cid
641
641
}
642
642
643
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
643
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
644
644
Collection: tangled.ActorProfileNSID,
645
645
Repo: user.Did,
646
646
Rkey: "self",
+2
-2
appview/state/star.go
+2
-2
appview/state/star.go
···
40
40
case http.MethodPost:
41
41
createdAt := time.Now().Format(time.RFC3339)
42
42
rkey := tid.TID()
43
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
43
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
44
44
Collection: tangled.FeedStarNSID,
45
45
Repo: currentUser.Did,
46
46
Rkey: rkey,
···
92
92
return
93
93
}
94
94
95
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
95
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
96
96
Collection: tangled.FeedStarNSID,
97
97
Repo: currentUser.Did,
98
98
Rkey: star.Rkey,
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+7
-2
appview/pages/templates/repo/issues/fragments/newComment.html
+7
-2
appview/pages/templates/repo/issues/fragments/newComment.html
···
138
138
</div>
139
139
</form>
140
140
{{ else }}
141
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
142
-
<a href="/login" class="underline">login</a> to join the discussion
141
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center">
142
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
143
+
sign up
144
+
</a>
145
+
<span class="text-gray-500 dark:text-gray-400">or</span>
146
+
<a href="/login" class="underline">login</a>
147
+
to add to the discussion
143
148
</div>
144
149
{{ end }}
145
150
{{ end }}
+4
-2
appview/pages/templates/repo/issues/issue.html
+4
-2
appview/pages/templates/repo/issues/issue.html
···
110
110
<div class="flex items-center gap-2">
111
111
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
112
112
{{ range $kind := .OrderedReactionKinds }}
113
+
{{ $reactionData := index $.Reactions $kind }}
113
114
{{
114
115
template "repo/fragments/reaction"
115
116
(dict
116
117
"Kind" $kind
117
-
"Count" (index $.Reactions $kind)
118
+
"Count" $reactionData.Count
118
119
"IsReacted" (index $.UserReacted $kind)
119
-
"ThreadAt" $.Issue.AtUri)
120
+
"ThreadAt" $.Issue.AtUri
121
+
"Users" $reactionData.Users)
120
122
}}
121
123
{{ end }}
122
124
</div>
+6
-1
appview/pages/markup/markdown.go
+6
-1
appview/pages/markup/markdown.go
···
5
5
"bytes"
6
6
"fmt"
7
7
"io"
8
+
"io/fs"
8
9
"net/url"
9
10
"path"
10
11
"strings"
···
20
21
"github.com/yuin/goldmark/renderer/html"
21
22
"github.com/yuin/goldmark/text"
22
23
"github.com/yuin/goldmark/util"
24
+
callout "gitlab.com/staticnoise/goldmark-callout"
23
25
htmlparse "golang.org/x/net/html"
24
26
25
27
"tangled.org/core/api/tangled"
···
45
47
IsDev bool
46
48
RendererType RendererType
47
49
Sanitizer Sanitizer
50
+
Files fs.FS
48
51
}
49
52
50
53
func (rctx *RenderContext) RenderMarkdown(source string) string {
···
62
65
extension.WithFootnoteIDPrefix([]byte("footnote")),
63
66
),
64
67
treeblood.MathML(),
68
+
callout.CalloutExtention,
65
69
),
66
70
goldmark.WithParserOptions(
67
71
parser.WithAutoHeadingID(),
···
140
144
func visitNode(ctx *RenderContext, node *htmlparse.Node) {
141
145
switch node.Type {
142
146
case htmlparse.ElementNode:
143
-
if node.Data == "img" || node.Data == "source" {
147
+
switch node.Data {
148
+
case "img", "source":
144
149
for i, attr := range node.Attr {
145
150
if attr.Key != "src" {
146
151
continue
+3
-3
appview/pages/funcmap.go
+3
-3
appview/pages/funcmap.go
···
283
283
},
284
284
285
285
"tinyAvatar": func(handle string) string {
286
-
return p.avatarUri(handle, "tiny")
286
+
return p.AvatarUrl(handle, "tiny")
287
287
},
288
288
"fullAvatar": func(handle string) string {
289
-
return p.avatarUri(handle, "")
289
+
return p.AvatarUrl(handle, "")
290
290
},
291
291
"langColor": enry.GetColor,
292
292
"layoutSide": func() string {
···
310
310
}
311
311
}
312
312
313
-
func (p *Pages) avatarUri(handle, size string) string {
313
+
func (p *Pages) AvatarUrl(handle, size string) string {
314
314
handle = strings.TrimPrefix(handle, "@")
315
315
316
316
secret := p.avatar.SharedSecret
+44
appview/pages/templates/fragments/dolly/silhouette.svg
+44
appview/pages/templates/fragments/dolly/silhouette.svg
···
1
+
<svg
2
+
version="1.1"
3
+
id="svg1"
4
+
width="32"
5
+
height="32"
6
+
viewBox="0 0 25 25"
7
+
sodipodi:docname="tangled_dolly_silhouette.png"
8
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
9
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
10
+
xmlns="http://www.w3.org/2000/svg"
11
+
xmlns:svg="http://www.w3.org/2000/svg">
12
+
<title>Dolly</title>
13
+
<defs
14
+
id="defs1" />
15
+
<sodipodi:namedview
16
+
id="namedview1"
17
+
pagecolor="#ffffff"
18
+
bordercolor="#000000"
19
+
borderopacity="0.25"
20
+
inkscape:showpageshadow="2"
21
+
inkscape:pageopacity="0.0"
22
+
inkscape:pagecheckerboard="true"
23
+
inkscape:deskcolor="#d1d1d1">
24
+
<inkscape:page
25
+
x="0"
26
+
y="0"
27
+
width="25"
28
+
height="25"
29
+
id="page2"
30
+
margin="0"
31
+
bleed="0" />
32
+
</sodipodi:namedview>
33
+
<g
34
+
inkscape:groupmode="layer"
35
+
inkscape:label="Image"
36
+
id="g1">
37
+
<path
38
+
class="dolly"
39
+
fill="currentColor"
40
+
style="stroke-width:1.12248"
41
+
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"
42
+
id="path1" />
43
+
</g>
44
+
</svg>
+11
knotserver/git/git.go
+11
knotserver/git/git.go
···
71
71
return &g, nil
72
72
}
73
73
74
+
// re-open a repository and update references
75
+
func (g *GitRepo) Refresh() error {
76
+
refreshed, err := PlainOpen(g.path)
77
+
if err != nil {
78
+
return err
79
+
}
80
+
81
+
*g = *refreshed
82
+
return nil
83
+
}
84
+
74
85
func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) {
75
86
commits := []*object.Commit{}
76
87
+1
-1
knotserver/xrpc/merge_check.go
+1
-1
knotserver/xrpc/merge_check.go
+30
api/tangled/repodeleteBranch.go
+30
api/tangled/repodeleteBranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.deleteBranch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch"
15
+
)
16
+
17
+
// RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call.
18
+
type RepoDeleteBranch_Input struct {
19
+
Branch string `json:"branch" cborgen:"branch"`
20
+
Repo string `json:"repo" cborgen:"repo"`
21
+
}
22
+
23
+
// RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch".
24
+
func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error {
25
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil {
26
+
return err
27
+
}
28
+
29
+
return nil
30
+
}
+5
knotserver/git/branch.go
+5
knotserver/git/branch.go
+30
lexicons/repo/deleteBranch.json
+30
lexicons/repo/deleteBranch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.deleteBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Delete a branch on this repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"branch"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"branch": {
22
+
"type": "string"
23
+
}
24
+
}
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
30
+
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
33
33
<span>comment</span>
34
34
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
35
35
</button>
36
+
{{ if .BranchDeleteStatus }}
37
+
<button
38
+
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
39
+
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
40
+
hx-swap="none"
41
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
42
+
{{ i "git-branch" "w-4 h-4" }}
43
+
<span>delete branch</span>
44
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
45
+
</button>
46
+
{{ end }}
36
47
{{ if and $isPushAllowed $isOpen $isLastRound }}
37
48
{{ $disabled := "" }}
38
49
{{ if $isConflicted }}
+3
-3
appview/pages/templates/repo/commit.html
+3
-3
appview/pages/templates/repo/commit.html
···
80
80
{{end}}
81
81
82
82
{{ define "topbarLayout" }}
83
-
<header class="px-1 col-span-full" style="z-index: 20;">
83
+
<header class="col-span-full" style="z-index: 20;">
84
84
{{ template "layouts/fragments/topbar" . }}
85
85
</header>
86
86
{{ end }}
87
87
88
88
{{ define "mainLayout" }}
89
-
<div class="px-1 col-span-full flex flex-col gap-4">
89
+
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
90
90
{{ block "contentLayout" . }}
91
91
{{ block "content" . }}{{ end }}
92
92
{{ end }}
···
105
105
{{ end }}
106
106
107
107
{{ define "footerLayout" }}
108
-
<footer class="px-1 col-span-full mt-12">
108
+
<footer class="col-span-full mt-12">
109
109
{{ template "layouts/fragments/footer" . }}
110
110
</footer>
111
111
{{ end }}
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
+1
-14
appview/pages/templates/repo/pulls/interdiff.html
···
28
28
29
29
{{ end }}
30
30
31
-
{{ define "topbarLayout" }}
32
-
<header class="px-1 col-span-full" style="z-index: 20;">
33
-
{{ template "layouts/fragments/topbar" . }}
34
-
</header>
35
-
{{ end }}
36
-
37
31
{{ define "mainLayout" }}
38
-
<div class="px-1 col-span-full flex flex-col gap-4">
32
+
<div class="px-1 col-span-full flex-grow flex flex-col gap-4">
39
33
{{ block "contentLayout" . }}
40
34
{{ block "content" . }}{{ end }}
41
35
{{ end }}
···
53
47
</div>
54
48
{{ end }}
55
49
56
-
{{ define "footerLayout" }}
57
-
<footer class="px-1 col-span-full mt-12">
58
-
{{ template "layouts/fragments/footer" . }}
59
-
</footer>
60
-
{{ end }}
61
-
62
-
63
50
{{ define "contentAfter" }}
64
51
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
65
52
{{end}}
+1
-1
appview/pages/templates/layouts/fragments/topbar.html
+1
-1
appview/pages/templates/layouts/fragments/topbar.html
···
1
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm bg-white dark:bg-gray-800">
2
+
<nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800">
3
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
5
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
cmd/gen.go
cmd/cborgen/cborgen.go
cmd/gen.go
cmd/cborgen/cborgen.go
+18
-18
knotserver/git.go
+18
-18
knotserver/git.go
···
13
13
"tangled.org/core/knotserver/git/service"
14
14
)
15
15
16
-
func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
16
+
func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
17
17
did := chi.URLParam(r, "did")
18
18
name := chi.URLParam(r, "name")
19
19
repoName, err := securejoin.SecureJoin(did, name)
20
20
if err != nil {
21
21
gitError(w, "repository not found", http.StatusNotFound)
22
-
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
22
+
h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
23
23
return
24
24
}
25
25
26
-
repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName)
26
+
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName)
27
27
if err != nil {
28
28
gitError(w, "repository not found", http.StatusNotFound)
29
-
d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
29
+
h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err)
30
30
return
31
31
}
32
32
···
46
46
47
47
if err := cmd.InfoRefs(); err != nil {
48
48
gitError(w, err.Error(), http.StatusInternalServerError)
49
-
d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
49
+
h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
50
50
return
51
51
}
52
52
case "git-receive-pack":
53
-
d.RejectPush(w, r, name)
53
+
h.RejectPush(w, r, name)
54
54
default:
55
55
gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden)
56
56
}
57
57
}
58
58
59
-
func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
59
+
func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
60
60
did := chi.URLParam(r, "did")
61
61
name := chi.URLParam(r, "name")
62
-
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
62
+
repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
63
63
if err != nil {
64
64
gitError(w, err.Error(), http.StatusInternalServerError)
65
-
d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
65
+
h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
66
66
return
67
67
}
68
68
···
77
77
gzipReader, err := gzip.NewReader(r.Body)
78
78
if err != nil {
79
79
gitError(w, err.Error(), http.StatusInternalServerError)
80
-
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
80
+
h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
81
81
return
82
82
}
83
83
defer gzipReader.Close()
···
88
88
w.Header().Set("Connection", "Keep-Alive")
89
89
w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
90
90
91
-
d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
91
+
h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
92
92
93
93
cmd := service.ServiceCommand{
94
94
GitProtocol: r.Header.Get("Git-Protocol"),
···
100
100
w.WriteHeader(http.StatusOK)
101
101
102
102
if err := cmd.UploadPack(); err != nil {
103
-
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
103
+
h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
104
104
return
105
105
}
106
106
}
107
107
108
-
func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
108
+
func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
109
109
did := chi.URLParam(r, "did")
110
110
name := chi.URLParam(r, "name")
111
-
_, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
111
+
_, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
112
112
if err != nil {
113
113
gitError(w, err.Error(), http.StatusForbidden)
114
-
d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
114
+
h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err)
115
115
return
116
116
}
117
117
118
-
d.RejectPush(w, r, name)
118
+
h.RejectPush(w, r, name)
119
119
}
120
120
121
-
func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
121
+
func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
122
122
// A text/plain response will cause git to print each line of the body
123
123
// prefixed with "remote: ".
124
124
w.Header().Set("content-type", "text/plain; charset=UTF-8")
···
131
131
ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
132
132
ownerHandle = strings.TrimPrefix(ownerHandle, "@")
133
133
if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
134
-
hostname := d.c.Server.Hostname
134
+
hostname := h.c.Server.Hostname
135
135
if strings.Contains(hostname, ":") {
136
136
hostname = strings.Split(hostname, ":")[0]
137
137
}
+16
-9
knotserver/router.go
+16
-9
knotserver/router.go
···
12
12
"tangled.org/core/knotserver/config"
13
13
"tangled.org/core/knotserver/db"
14
14
"tangled.org/core/knotserver/xrpc"
15
-
tlog "tangled.org/core/log"
15
+
"tangled.org/core/log"
16
16
"tangled.org/core/notifier"
17
17
"tangled.org/core/rbac"
18
18
"tangled.org/core/xrpc/serviceauth"
···
28
28
resolver *idresolver.Resolver
29
29
}
30
30
31
-
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) {
32
-
r := chi.NewRouter()
33
-
31
+
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) {
34
32
h := Knot{
35
33
c: c,
36
34
db: db,
37
35
e: e,
38
-
l: l,
36
+
l: log.FromContext(ctx),
39
37
jc: jc,
40
38
n: n,
41
39
resolver: idresolver.DefaultResolver(),
···
67
65
return nil, fmt.Errorf("failed to start jetstream: %w", err)
68
66
}
69
67
68
+
return h.Router(), nil
69
+
}
70
+
71
+
func (h *Knot) Router() http.Handler {
72
+
r := chi.NewRouter()
73
+
74
+
r.Use(h.RequestLogger)
75
+
70
76
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
71
77
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
72
78
})
···
86
92
// Socket that streams git oplogs
87
93
r.Get("/events", h.Events)
88
94
89
-
return r, nil
95
+
return r
90
96
}
91
97
92
98
func (h *Knot) XrpcRouter() http.Handler {
93
-
logger := tlog.New("knots")
94
-
95
99
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
96
100
101
+
l := log.SubLogger(h.l, "xrpc")
102
+
97
103
xrpc := &xrpc.Xrpc{
98
104
Config: h.c,
99
105
Db: h.db,
100
106
Ingester: h.jc,
101
107
Enforcer: h.e,
102
-
Logger: logger,
108
+
Logger: l,
103
109
Notifier: h.n,
104
110
Resolver: h.resolver,
105
111
ServiceAuth: serviceAuth,
106
112
}
113
+
107
114
return xrpc.Router()
108
115
}
109
116
+5
-4
knotserver/server.go
+5
-4
knotserver/server.go
···
43
43
44
44
func Run(ctx context.Context, cmd *cli.Command) error {
45
45
logger := log.FromContext(ctx)
46
-
iLogger := log.New("knotserver/internal")
46
+
logger = log.SubLogger(logger, cmd.Name)
47
+
ctx = log.IntoContext(ctx, logger)
47
48
48
49
c, err := config.Load(ctx)
49
50
if err != nil {
···
80
81
tangled.KnotMemberNSID,
81
82
tangled.RepoPullNSID,
82
83
tangled.RepoCollaboratorNSID,
83
-
}, nil, logger, db, true, c.Server.LogDids)
84
+
}, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids)
84
85
if err != nil {
85
86
logger.Error("failed to setup jetstream", "error", err)
86
87
}
87
88
88
89
notifier := notifier.New()
89
90
90
-
mux, err := Setup(ctx, c, db, e, jc, logger, ¬ifier)
91
+
mux, err := Setup(ctx, c, db, e, jc, ¬ifier)
91
92
if err != nil {
92
93
return fmt.Errorf("failed to setup server: %w", err)
93
94
}
94
95
95
-
imux := Internal(ctx, c, db, e, iLogger, ¬ifier)
96
+
imux := Internal(ctx, c, db, e, ¬ifier)
96
97
97
98
logger.Info("starting internal server", "address", c.Server.InternalListenAddr)
98
99
go http.ListenAndServe(c.Server.InternalListenAddr, imux)
+36
-28
appview/db/db.go
+36
-28
appview/db/db.go
···
4
4
"context"
5
5
"database/sql"
6
6
"fmt"
7
-
"log"
7
+
"log/slog"
8
8
"reflect"
9
9
"strings"
10
10
11
11
_ "github.com/mattn/go-sqlite3"
12
+
"tangled.org/core/log"
12
13
)
13
14
14
15
type DB struct {
15
16
*sql.DB
17
+
logger *slog.Logger
16
18
}
17
19
18
20
type Execer interface {
···
26
28
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
27
29
}
28
30
29
-
func Make(dbPath string) (*DB, error) {
31
+
func Make(ctx context.Context, dbPath string) (*DB, error) {
30
32
// https://github.com/mattn/go-sqlite3#connection-string
31
33
opts := []string{
32
34
"_foreign_keys=1",
···
35
37
"_auto_vacuum=incremental",
36
38
}
37
39
40
+
logger := log.FromContext(ctx)
41
+
logger = log.SubLogger(logger, "db")
42
+
38
43
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
39
44
if err != nil {
40
45
return nil, err
41
46
}
42
47
43
-
ctx := context.Background()
44
-
45
48
conn, err := db.Conn(ctx)
46
49
if err != nil {
47
50
return nil, err
···
574
577
}
575
578
576
579
// run migrations
577
-
runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
580
+
runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error {
578
581
tx.Exec(`
579
582
alter table repos add column description text check (length(description) <= 200);
580
583
`)
581
584
return nil
582
585
})
583
586
584
-
runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
587
+
runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
585
588
// add unconstrained column
586
589
_, err := tx.Exec(`
587
590
alter table public_keys
···
604
607
return nil
605
608
})
606
609
607
-
runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
610
+
runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error {
608
611
_, err := tx.Exec(`
609
612
alter table comments drop column comment_at;
610
613
alter table comments add column rkey text;
···
612
615
return err
613
616
})
614
617
615
-
runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
618
+
runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
616
619
_, err := tx.Exec(`
617
620
alter table comments add column deleted text; -- timestamp
618
621
alter table comments add column edited text; -- timestamp
···
620
623
return err
621
624
})
622
625
623
-
runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
626
+
runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
624
627
_, err := tx.Exec(`
625
628
alter table pulls add column source_branch text;
626
629
alter table pulls add column source_repo_at text;
···
629
632
return err
630
633
})
631
634
632
-
runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
635
+
runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error {
633
636
_, err := tx.Exec(`
634
637
alter table repos add column source text;
635
638
`)
···
641
644
//
642
645
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
643
646
conn.ExecContext(ctx, "pragma foreign_keys = off;")
644
-
runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
647
+
runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
645
648
_, err := tx.Exec(`
646
649
create table pulls_new (
647
650
-- identifiers
···
698
701
})
699
702
conn.ExecContext(ctx, "pragma foreign_keys = on;")
700
703
701
-
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
704
+
runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error {
702
705
tx.Exec(`
703
706
alter table repos add column spindle text;
704
707
`)
···
708
711
// drop all knot secrets, add unique constraint to knots
709
712
//
710
713
// knots will henceforth use service auth for signed requests
711
-
runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error {
714
+
runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error {
712
715
_, err := tx.Exec(`
713
716
create table registrations_new (
714
717
id integer primary key autoincrement,
···
731
734
})
732
735
733
736
// recreate and add rkey + created columns with default constraint
734
-
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
737
+
runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error {
735
738
// create new table
736
739
// - repo_at instead of repo integer
737
740
// - rkey field
···
785
788
return err
786
789
})
787
790
788
-
runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
791
+
runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error {
789
792
_, err := tx.Exec(`
790
793
alter table issues add column rkey text not null default '';
791
794
···
797
800
})
798
801
799
802
// repurpose the read-only column to "needs-upgrade"
800
-
runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
803
+
runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
801
804
_, err := tx.Exec(`
802
805
alter table registrations rename column read_only to needs_upgrade;
803
806
`)
···
805
808
})
806
809
807
810
// require all knots to upgrade after the release of total xrpc
808
-
runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
811
+
runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
809
812
_, err := tx.Exec(`
810
813
update registrations set needs_upgrade = 1;
811
814
`)
···
813
816
})
814
817
815
818
// require all knots to upgrade after the release of total xrpc
816
-
runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
819
+
runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
817
820
_, err := tx.Exec(`
818
821
alter table spindles add column needs_upgrade integer not null default 0;
819
822
`)
···
831
834
//
832
835
// disable foreign-keys for the next migration
833
836
conn.ExecContext(ctx, "pragma foreign_keys = off;")
834
-
runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
837
+
runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
835
838
_, err := tx.Exec(`
836
839
create table if not exists issues_new (
837
840
-- identifiers
···
901
904
// - new columns
902
905
// * column "reply_to" which can be any other comment
903
906
// * column "at-uri" which is a generated column
904
-
runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
907
+
runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error {
905
908
_, err := tx.Exec(`
906
909
create table if not exists issue_comments (
907
910
-- identifiers
···
961
964
//
962
965
// disable foreign-keys for the next migration
963
966
conn.ExecContext(ctx, "pragma foreign_keys = off;")
964
-
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
967
+
runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
965
968
_, err := tx.Exec(`
966
969
create table if not exists pulls_new (
967
970
-- identifiers
···
1042
1045
//
1043
1046
// disable foreign-keys for the next migration
1044
1047
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1045
-
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1048
+
runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1046
1049
_, err := tx.Exec(`
1047
1050
create table if not exists pull_submissions_new (
1048
1051
-- identifiers
···
1094
1097
})
1095
1098
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1096
1099
1097
-
return &DB{db}, nil
1100
+
return &DB{
1101
+
db,
1102
+
logger,
1103
+
}, nil
1098
1104
}
1099
1105
1100
1106
type migrationFn = func(*sql.Tx) error
1101
1107
1102
-
func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
1108
+
func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error {
1109
+
logger = logger.With("migration", name)
1110
+
1103
1111
tx, err := c.BeginTx(context.Background(), nil)
1104
1112
if err != nil {
1105
1113
return err
···
1116
1124
// run migration
1117
1125
err = migrationFn(tx)
1118
1126
if err != nil {
1119
-
log.Printf("Failed to run migration %s: %v", name, err)
1127
+
logger.Error("failed to run migration", "err", err)
1120
1128
return err
1121
1129
}
1122
1130
1123
1131
// mark migration as complete
1124
1132
_, err = tx.Exec("insert into migrations (name) values (?)", name)
1125
1133
if err != nil {
1126
-
log.Printf("Failed to mark migration %s as complete: %v", name, err)
1134
+
logger.Error("failed to mark migration as complete", "err", err)
1127
1135
return err
1128
1136
}
1129
1137
···
1132
1140
return err
1133
1141
}
1134
1142
1135
-
log.Printf("migration %s applied successfully", name)
1143
+
logger.Info("migration applied successfully")
1136
1144
} else {
1137
-
log.Printf("skipped migration %s, already applied", name)
1145
+
logger.Warn("skipped migration, already applied")
1138
1146
}
1139
1147
1140
1148
return nil
+27
-26
appview/issues/issues.go
+27
-26
appview/issues/issues.go
···
5
5
"database/sql"
6
6
"errors"
7
7
"fmt"
8
-
"log"
9
8
"log/slog"
10
9
"net/http"
11
10
"slices"
···
28
27
"tangled.org/core/appview/reporesolver"
29
28
"tangled.org/core/appview/validator"
30
29
"tangled.org/core/idresolver"
31
-
tlog "tangled.org/core/log"
32
30
"tangled.org/core/tid"
33
31
)
34
32
···
53
51
config *config.Config,
54
52
notifier notify.Notifier,
55
53
validator *validator.Validator,
54
+
logger *slog.Logger,
56
55
) *Issues {
57
56
return &Issues{
58
57
oauth: oauth,
···
62
61
db: db,
63
62
config: config,
64
63
notifier: notifier,
65
-
logger: tlog.New("issues"),
64
+
logger: logger,
66
65
validator: validator,
67
66
}
68
67
}
···
72
71
user := rp.oauth.GetUser(r)
73
72
f, err := rp.repoResolver.Resolve(r)
74
73
if err != nil {
75
-
log.Println("failed to get repo and knot", err)
74
+
l.Error("failed to get repo and knot", "err", err)
76
75
return
77
76
}
78
77
···
99
98
db.FilterContains("scope", tangled.RepoIssueNSID),
100
99
)
101
100
if err != nil {
102
-
log.Println("failed to fetch labels", err)
101
+
l.Error("failed to fetch labels", "err", err)
103
102
rp.pages.Error503(w)
104
103
return
105
104
}
···
126
125
user := rp.oauth.GetUser(r)
127
126
f, err := rp.repoResolver.Resolve(r)
128
127
if err != nil {
129
-
log.Println("failed to get repo and knot", err)
128
+
l.Error("failed to get repo and knot", "err", err)
130
129
return
131
130
}
132
131
···
199
198
200
199
err = db.PutIssue(tx, newIssue)
201
200
if err != nil {
202
-
log.Println("failed to edit issue", err)
201
+
l.Error("failed to edit issue", "err", err)
203
202
rp.pages.Notice(w, "issues", "Failed to edit issue.")
204
203
return
205
204
}
···
237
236
// delete from PDS
238
237
client, err := rp.oauth.AuthorizedClient(r)
239
238
if err != nil {
240
-
log.Println("failed to get authorized client", err)
239
+
l.Error("failed to get authorized client", "err", err)
241
240
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
242
241
return
243
242
}
···
282
281
283
282
collaborators, err := f.Collaborators(r.Context())
284
283
if err != nil {
285
-
log.Println("failed to fetch repo collaborators: %w", err)
284
+
l.Error("failed to fetch repo collaborators", "err", err)
286
285
}
287
286
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
288
287
return user.Did == collab.Did
···
296
295
db.FilterEq("id", issue.Id),
297
296
)
298
297
if err != nil {
299
-
log.Println("failed to close issue", err)
298
+
l.Error("failed to close issue", "err", err)
300
299
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
301
300
return
302
301
}
···
307
306
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
308
307
return
309
308
} else {
310
-
log.Println("user is not permitted to close issue")
309
+
l.Error("user is not permitted to close issue")
311
310
http.Error(w, "for biden", http.StatusUnauthorized)
312
311
return
313
312
}
···
318
317
user := rp.oauth.GetUser(r)
319
318
f, err := rp.repoResolver.Resolve(r)
320
319
if err != nil {
321
-
log.Println("failed to get repo and knot", err)
320
+
l.Error("failed to get repo and knot", "err", err)
322
321
return
323
322
}
324
323
···
331
330
332
331
collaborators, err := f.Collaborators(r.Context())
333
332
if err != nil {
334
-
log.Println("failed to fetch repo collaborators: %w", err)
333
+
l.Error("failed to fetch repo collaborators", "err", err)
335
334
}
336
335
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
337
336
return user.Did == collab.Did
···
344
343
db.FilterEq("id", issue.Id),
345
344
)
346
345
if err != nil {
347
-
log.Println("failed to reopen issue", err)
346
+
l.Error("failed to reopen issue", "err", err)
348
347
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
349
348
return
350
349
}
351
350
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
352
351
return
353
352
} else {
354
-
log.Println("user is not the owner of the repo")
353
+
l.Error("user is not the owner of the repo")
355
354
http.Error(w, "forbidden", http.StatusUnauthorized)
356
355
return
357
356
}
···
538
537
newBody := r.FormValue("body")
539
538
client, err := rp.oauth.AuthorizedClient(r)
540
539
if err != nil {
541
-
log.Println("failed to get authorized client", err)
540
+
l.Error("failed to get authorized client", "err", err)
542
541
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
543
542
return
544
543
}
···
551
550
552
551
_, err = db.AddIssueComment(rp.db, newComment)
553
552
if err != nil {
554
-
log.Println("failed to perferom update-description query", err)
553
+
l.Error("failed to perferom update-description query", "err", err)
555
554
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
556
555
return
557
556
}
···
561
560
// update the record on pds
562
561
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
563
562
if err != nil {
564
-
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
563
+
l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
565
564
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
566
565
return
567
566
}
···
729
728
if comment.Rkey != "" {
730
729
client, err := rp.oauth.AuthorizedClient(r)
731
730
if err != nil {
732
-
log.Println("failed to get authorized client", err)
731
+
l.Error("failed to get authorized client", "err", err)
733
732
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
734
733
return
735
734
}
···
739
738
Rkey: comment.Rkey,
740
739
})
741
740
if err != nil {
742
-
log.Println(err)
741
+
l.Error("failed to delete from PDS", "err", err)
743
742
}
744
743
}
745
744
···
757
756
}
758
757
759
758
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
759
+
l := rp.logger.With("handler", "RepoIssues")
760
+
760
761
params := r.URL.Query()
761
762
state := params.Get("state")
762
763
isOpen := true
···
771
772
772
773
page, ok := r.Context().Value("page").(pagination.Page)
773
774
if !ok {
774
-
log.Println("failed to get page")
775
+
l.Error("failed to get page")
775
776
page = pagination.FirstPage()
776
777
}
777
778
778
779
user := rp.oauth.GetUser(r)
779
780
f, err := rp.repoResolver.Resolve(r)
780
781
if err != nil {
781
-
log.Println("failed to get repo and knot", err)
782
+
l.Error("failed to get repo and knot", "err", err)
782
783
return
783
784
}
784
785
···
793
794
db.FilterEq("open", openVal),
794
795
)
795
796
if err != nil {
796
-
log.Println("failed to get issues", err)
797
+
l.Error("failed to get issues", "err", err)
797
798
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
798
799
return
799
800
}
···
804
805
db.FilterContains("scope", tangled.RepoIssueNSID),
805
806
)
806
807
if err != nil {
807
-
log.Println("failed to fetch labels", err)
808
+
l.Error("failed to fetch labels", "err", err)
808
809
rp.pages.Error503(w)
809
810
return
810
811
}
···
901
902
902
903
err = db.PutIssue(tx, issue)
903
904
if err != nil {
904
-
log.Println("failed to create issue", err)
905
+
l.Error("failed to create issue", "err", err)
905
906
rp.pages.Notice(w, "issues", "Failed to create issue.")
906
907
return
907
908
}
908
909
909
910
if err = tx.Commit(); err != nil {
910
-
log.Println("failed to create issue", err)
911
+
l.Error("failed to create issue", "err", err)
911
912
rp.pages.Notice(w, "issues", "Failed to create issue.")
912
913
return
913
914
}
+15
-12
appview/notifications/notifications.go
+15
-12
appview/notifications/notifications.go
···
1
1
package notifications
2
2
3
3
import (
4
-
"log"
4
+
"log/slog"
5
5
"net/http"
6
6
"strconv"
7
7
···
14
14
)
15
15
16
16
type Notifications struct {
17
-
db *db.DB
18
-
oauth *oauth.OAuth
19
-
pages *pages.Pages
17
+
db *db.DB
18
+
oauth *oauth.OAuth
19
+
pages *pages.Pages
20
+
logger *slog.Logger
20
21
}
21
22
22
-
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications {
23
+
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications {
23
24
return &Notifications{
24
-
db: database,
25
-
oauth: oauthHandler,
26
-
pages: pagesHandler,
25
+
db: database,
26
+
oauth: oauthHandler,
27
+
pages: pagesHandler,
28
+
logger: logger,
27
29
}
28
30
}
29
31
···
44
46
}
45
47
46
48
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
49
+
l := n.logger.With("handler", "notificationsPage")
47
50
user := n.oauth.GetUser(r)
48
51
49
52
page, ok := r.Context().Value("page").(pagination.Page)
50
53
if !ok {
51
-
log.Println("failed to get page")
54
+
l.Error("failed to get page")
52
55
page = pagination.FirstPage()
53
56
}
54
57
···
57
60
db.FilterEq("recipient_did", user.Did),
58
61
)
59
62
if err != nil {
60
-
log.Println("failed to get total notifications:", err)
63
+
l.Error("failed to get total notifications", "err", err)
61
64
n.pages.Error500(w)
62
65
return
63
66
}
···
68
71
db.FilterEq("recipient_did", user.Did),
69
72
)
70
73
if err != nil {
71
-
log.Println("failed to get notifications:", err)
74
+
l.Error("failed to get notifications", "err", err)
72
75
n.pages.Error500(w)
73
76
return
74
77
}
75
78
76
79
err = n.db.MarkAllNotificationsRead(r.Context(), user.Did)
77
80
if err != nil {
78
-
log.Println("failed to mark notifications as read:", err)
81
+
l.Error("failed to mark notifications as read", "err", err)
79
82
}
80
83
81
84
unreadCount := 0
+4
appview/pulls/pulls.go
+4
appview/pulls/pulls.go
···
6
6
"errors"
7
7
"fmt"
8
8
"log"
9
+
"log/slog"
9
10
"net/http"
10
11
"slices"
11
12
"sort"
···
46
47
config *config.Config
47
48
notifier notify.Notifier
48
49
enforcer *rbac.Enforcer
50
+
logger *slog.Logger
49
51
}
50
52
51
53
func New(
···
57
59
config *config.Config,
58
60
notifier notify.Notifier,
59
61
enforcer *rbac.Enforcer,
62
+
logger *slog.Logger,
60
63
) *Pulls {
61
64
return &Pulls{
62
65
oauth: oauth,
···
67
70
config: config,
68
71
notifier: notifier,
69
72
enforcer: enforcer,
73
+
logger: logger,
70
74
}
71
75
}
72
76
+14
-11
appview/repo/index.go
+14
-11
appview/repo/index.go
···
3
3
import (
4
4
"errors"
5
5
"fmt"
6
-
"log"
6
+
"log/slog"
7
7
"net/http"
8
8
"net/url"
9
9
"slices"
···
31
31
)
32
32
33
33
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
34
+
l := rp.logger.With("handler", "RepoIndex")
35
+
34
36
ref := chi.URLParam(r, "ref")
35
37
ref, _ = url.PathUnescape(ref)
36
38
37
39
f, err := rp.repoResolver.Resolve(r)
38
40
if err != nil {
39
-
log.Println("failed to fully resolve repo", err)
41
+
l.Error("failed to fully resolve repo", "err", err)
40
42
return
41
43
}
42
44
···
56
58
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
57
59
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
58
60
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
59
-
log.Println("failed to call XRPC repo.index", err)
61
+
l.Error("failed to call XRPC repo.index", "err", err)
60
62
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
61
63
LoggedInUser: user,
62
64
NeedsKnotUpgrade: true,
···
66
68
}
67
69
68
70
rp.pages.Error503(w)
69
-
log.Println("failed to build index response", err)
71
+
l.Error("failed to build index response", "err", err)
70
72
return
71
73
}
72
74
···
119
121
emails := uniqueEmails(commitsTrunc)
120
122
emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
121
123
if err != nil {
122
-
log.Println("failed to get email to did map", err)
124
+
l.Error("failed to get email to did map", "err", err)
123
125
}
124
126
125
127
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
126
128
if err != nil {
127
-
log.Println(err)
129
+
l.Error("failed to GetVerifiedObjectCommits", "err", err)
128
130
}
129
131
130
132
// TODO: a bit dirty
131
-
languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
133
+
languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "")
132
134
if err != nil {
133
-
log.Printf("failed to compute language percentages: %s", err)
135
+
l.Warn("failed to compute language percentages", "err", err)
134
136
// non-fatal
135
137
}
136
138
···
140
142
}
141
143
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
142
144
if err != nil {
143
-
log.Printf("failed to fetch pipeline statuses: %s", err)
145
+
l.Error("failed to fetch pipeline statuses", "err", err)
144
146
// non-fatal
145
147
}
146
148
···
162
164
163
165
func (rp *Repo) getLanguageInfo(
164
166
ctx context.Context,
167
+
l *slog.Logger,
165
168
f *reporesolver.ResolvedRepo,
166
169
xrpcc *indigoxrpc.Client,
167
170
currentRef string,
···
180
183
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
181
184
if err != nil {
182
185
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
183
-
log.Println("failed to call XRPC repo.languages", xrpcerr)
186
+
l.Error("failed to call XRPC repo.languages", "err", xrpcerr)
184
187
return nil, xrpcerr
185
188
}
186
189
return nil, err
···
210
213
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
211
214
if err != nil {
212
215
// non-fatal
213
-
log.Println("failed to cache lang results", err)
216
+
l.Error("failed to cache lang results", "err", err)
214
217
}
215
218
216
219
err = tx.Commit()
+3
-1
appview/state/spindlestream.go
+3
-1
appview/state/spindlestream.go
···
22
22
)
23
23
24
24
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
25
+
logger := log.FromContext(ctx)
26
+
logger = log.SubLogger(logger, "spindlestream")
27
+
25
28
spindles, err := db.GetSpindles(
26
29
d,
27
30
db.FilterIsNot("verified", "null"),
···
36
39
srcs[src] = struct{}{}
37
40
}
38
41
39
-
logger := log.New("spindlestream")
40
42
cache := cache.New(c.Redis.Addr)
41
43
cursorStore := cursor.NewRedisCursorStore(cache)
42
44
+1
-1
jetstream/jetstream.go
+1
-1
jetstream/jetstream.go
···
114
114
115
115
sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc))
116
116
117
-
client, err := client.NewClient(j.cfg, log.New("jetstream"), sched)
117
+
client, err := client.NewClient(j.cfg, logger, sched)
118
118
if err != nil {
119
119
return fmt.Errorf("failed to create jetstream client: %w", err)
120
120
}
+5
-4
xrpc/serviceauth/service_auth.go
+5
-4
xrpc/serviceauth/service_auth.go
···
9
9
10
10
"github.com/bluesky-social/indigo/atproto/auth"
11
11
"tangled.org/core/idresolver"
12
+
"tangled.org/core/log"
12
13
xrpcerr "tangled.org/core/xrpc/errors"
13
14
)
14
15
···
22
23
23
24
func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth {
24
25
return &ServiceAuth{
25
-
logger: logger,
26
+
logger: log.SubLogger(logger, "serviceauth"),
26
27
resolver: resolver,
27
28
audienceDid: audienceDid,
28
29
}
···
30
31
31
32
func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler {
32
33
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33
-
l := sa.logger.With("url", r.URL)
34
-
35
34
token := r.Header.Get("Authorization")
36
35
token = strings.TrimPrefix(token, "Bearer ")
37
36
···
42
41
43
42
did, err := s.Validate(r.Context(), token, nil)
44
43
if err != nil {
45
-
l.Error("signature verification failed", "err", err)
44
+
sa.logger.Error("signature verification failed", "err", err)
46
45
writeError(w, xrpcerr.AuthError(err), http.StatusForbidden)
47
46
return
48
47
}
49
48
49
+
sa.logger.Debug("valid signature", ActorDid, did)
50
+
50
51
r = r.WithContext(
51
52
context.WithValue(r.Context(), ActorDid, did),
52
53
)
+35
spindle/middleware.go
+35
spindle/middleware.go
···
1
+
package spindle
2
+
3
+
import (
4
+
"log/slog"
5
+
"net/http"
6
+
"time"
7
+
)
8
+
9
+
func (s *Spindle) RequestLogger(next http.Handler) http.Handler {
10
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11
+
start := time.Now()
12
+
13
+
next.ServeHTTP(w, r)
14
+
15
+
// Build query params as slog.Attrs for the group
16
+
queryParams := r.URL.Query()
17
+
queryAttrs := make([]any, 0, len(queryParams))
18
+
for key, values := range queryParams {
19
+
if len(values) == 1 {
20
+
queryAttrs = append(queryAttrs, slog.String(key, values[0]))
21
+
} else {
22
+
queryAttrs = append(queryAttrs, slog.Any(key, values))
23
+
}
24
+
}
25
+
26
+
s.l.LogAttrs(r.Context(), slog.LevelInfo, "",
27
+
slog.Group("request",
28
+
slog.String("method", r.Method),
29
+
slog.String("path", r.URL.Path),
30
+
slog.Group("query", queryAttrs...),
31
+
slog.Duration("duration", time.Since(start)),
32
+
),
33
+
)
34
+
})
35
+
}
+6
-6
spindle/server.go
+6
-6
spindle/server.go
···
108
108
tangled.RepoNSID,
109
109
tangled.RepoCollaboratorNSID,
110
110
}
111
-
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
111
+
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
112
112
if err != nil {
113
113
return fmt.Errorf("failed to setup jetstream client: %w", err)
114
114
}
···
171
171
// spindle.processPipeline, which in turn enqueues the pipeline
172
172
// job in the above registered queue.
173
173
ccfg := eventconsumer.NewConsumerConfig()
174
-
ccfg.Logger = logger
174
+
ccfg.Logger = log.SubLogger(logger, "eventconsumer")
175
175
ccfg.Dev = cfg.Server.Dev
176
176
ccfg.ProcessFunc = spindle.processPipeline
177
177
ccfg.CursorStore = cursorStore
···
210
210
}
211
211
212
212
func (s *Spindle) XrpcRouter() http.Handler {
213
-
logger := s.l.With("route", "xrpc")
214
-
215
213
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
216
214
215
+
l := log.SubLogger(s.l, "xrpc")
216
+
217
217
x := xrpc.Xrpc{
218
-
Logger: logger,
218
+
Logger: l,
219
219
Db: s.db,
220
220
Enforcer: s.e,
221
221
Engines: s.engs,
···
305
305
306
306
ok := s.jq.Enqueue(queue.Job{
307
307
Run: func() error {
308
-
engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
308
+
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
309
309
RepoOwner: tpl.TriggerMetadata.Repo.Did,
310
310
RepoName: tpl.TriggerMetadata.Repo.Repo,
311
311
Workflows: workflows,
+1
-1
appview/repo/repo.go
+1
-1
appview/repo/repo.go
+15
-12
appview/oauth/oauth.go
+15
-12
appview/oauth/oauth.go
···
3
3
import (
4
4
"errors"
5
5
"fmt"
6
+
"log/slog"
6
7
"net/http"
7
8
"time"
8
9
···
20
21
"tangled.org/core/rbac"
21
22
)
22
23
23
-
func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver) (*OAuth, error) {
24
+
type OAuth struct {
25
+
ClientApp *oauth.ClientApp
26
+
SessStore *sessions.CookieStore
27
+
Config *config.Config
28
+
JwksUri string
29
+
Posthog posthog.Client
30
+
Db *db.DB
31
+
Enforcer *rbac.Enforcer
32
+
IdResolver *idresolver.Resolver
33
+
Logger *slog.Logger
34
+
}
35
+
36
+
func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) {
24
37
25
38
var oauthConfig oauth.ClientConfig
26
39
var clientUri string
···
54
67
Db: db,
55
68
Enforcer: enforcer,
56
69
IdResolver: res,
70
+
Logger: logger,
57
71
}, nil
58
72
}
59
73
60
-
type OAuth struct {
61
-
ClientApp *oauth.ClientApp
62
-
SessStore *sessions.CookieStore
63
-
Config *config.Config
64
-
JwksUri string
65
-
Posthog posthog.Client
66
-
Db *db.DB
67
-
Enforcer *rbac.Enforcer
68
-
IdResolver *idresolver.Resolver
69
-
}
70
-
71
74
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error {
72
75
// first we save the did in the user session
73
76
userSession, err := o.SessStore.Get(r, SessionName)
+1
-1
appview/state/state.go
+1
-1
appview/state/state.go
···
82
82
}
83
83
84
84
pages := pages.NewPages(config, res, log.SubLogger(logger, "pages"))
85
-
oauth, err := oauth.New(config, posthog, d, enforcer, res)
85
+
oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth"))
86
86
if err != nil {
87
87
return nil, fmt.Errorf("failed to start oauth handler: %w", err)
88
88
}