forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+7981 -4897
.air
.tangled
workflows
.zed
api
appview
config
db
issues
models
notifications
notify
oauth
pages
markup
repoinfo
templates
brand
errors
fragments
goodfirstissues
knots
labels
fragments
layouts
notifications
fragments
repo
spindles
strings
timeline
user
pagination
pipelines
pulls
repo
reporesolver
serververify
signup
spindles
state
validator
cmd
appview
cborgen
combinediff
interdiff
spindle
verifysig
consts
contrib
crypto
docs
eventconsumer
guard
hook
idresolver
keyfetch
knotserver
legal
lexicons
nix
patchutil
rbac
spindle
types
workflow
xrpc
errors
serviceauth
+85 -54
lexicons/pipeline.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["triggerMetadata", "workflows"], 13 "properties": { 14 "triggerMetadata": { 15 "type": "ref", ··· 27 }, 28 "triggerMetadata": { 29 "type": "object", 30 - "required": ["kind", "repo"], 31 "properties": { 32 "kind": { 33 "type": "string", 34 - "enum": ["push", "pull_request", "manual"] 35 }, 36 "repo": { 37 "type": "ref", ··· 53 }, 54 "triggerRepo": { 55 "type": "object", 56 - "required": ["knot", "did", "repo", "defaultBranch"], 57 "properties": { 58 "knot": { 59 "type": "string" ··· 72 }, 73 "pushTriggerData": { 74 "type": "object", 75 - "required": ["ref", "newSha", "oldSha"], 76 "properties": { 77 "ref": { 78 "type": "string" ··· 91 }, 92 "pullRequestTriggerData": { 93 "type": "object", 94 - "required": ["sourceBranch", "targetBranch", "sourceSha", "action"], 95 "properties": { 96 "sourceBranch": { 97 "type": "string" ··· 115 "inputs": { 116 "type": "array", 117 "items": { 118 - "type": "object", 119 - "required": ["key", "value"], 120 - "properties": { 121 - "key": { 122 - "type": "string" 123 - }, 124 - "value": { 125 - "type": "string" 126 - } 127 - } 128 } 129 } 130 } 131 }, 132 "workflow": { 133 "type": "object", 134 - "required": ["name", "dependencies", "steps", "environment", "clone"], 135 "properties": { 136 "name": { 137 "type": "string" 138 }, 139 "dependencies": { 140 - "type": "ref", 141 - "ref": "#dependencies" 142 }, 143 "steps": { 144 "type": "array", ··· 150 "environment": { 151 "type": "array", 152 "items": { 153 - "type": "object", 154 - "required": ["key", "value"], 155 - "properties": { 156 - "key": { 157 - "type": "string" 158 - }, 159 - "value": { 160 - "type": "string" 161 - } 162 - } 163 } 164 }, 165 "clone": { ··· 168 } 169 } 170 }, 171 - "dependencies": { 172 - "type": "array", 173 - "items": { 174 - "type": "object", 175 - "required": ["registry", "packages"], 176 - "properties": { 177 - "registry": { 178 "type": "string" 179 - }, 180 - "packages": { 181 - "type": "array", 182 - "items": { 183 - "type": "string" 184 - } 185 } 186 } 187 } 188 }, 189 "cloneOpts": { 190 "type": "object", 191 - "required": ["skip", "depth", "submodules"], 192 "properties": { 193 "skip": { 194 "type": "boolean" ··· 203 }, 204 "step": { 205 "type": "object", 206 - "required": ["name", "command"], 207 "properties": { 208 "name": { 209 "type": "string" ··· 214 "environment": { 215 "type": "array", 216 "items": { 217 - "type": "object", 218 - "required": ["key", "value"], 219 - "properties": { 220 - "key": { 221 - "type": "string" 222 - }, 223 - "value": { 224 - "type": "string" 225 - } 226 - } 227 } 228 } 229 } 230 } 231 } 232 }
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": [ 13 + "triggerMetadata", 14 + "workflows" 15 + ], 16 "properties": { 17 "triggerMetadata": { 18 "type": "ref", ··· 30 }, 31 "triggerMetadata": { 32 "type": "object", 33 + "required": [ 34 + "kind", 35 + "repo" 36 + ], 37 "properties": { 38 "kind": { 39 "type": "string", 40 + "enum": [ 41 + "push", 42 + "pull_request", 43 + "manual" 44 + ] 45 }, 46 "repo": { 47 "type": "ref", ··· 63 }, 64 "triggerRepo": { 65 "type": "object", 66 + "required": [ 67 + "knot", 68 + "did", 69 + "repo", 70 + "defaultBranch" 71 + ], 72 "properties": { 73 "knot": { 74 "type": "string" ··· 87 }, 88 "pushTriggerData": { 89 "type": "object", 90 + "required": [ 91 + "ref", 92 + "newSha", 93 + "oldSha" 94 + ], 95 "properties": { 96 "ref": { 97 "type": "string" ··· 110 }, 111 "pullRequestTriggerData": { 112 "type": "object", 113 + "required": [ 114 + "sourceBranch", 115 + "targetBranch", 116 + "sourceSha", 117 + "action" 118 + ], 119 "properties": { 120 "sourceBranch": { 121 "type": "string" ··· 139 "inputs": { 140 "type": "array", 141 "items": { 142 + "type": "ref", 143 + "ref": "#pair" 144 } 145 } 146 } 147 }, 148 "workflow": { 149 "type": "object", 150 + "required": [ 151 + "name", 152 + "dependencies", 153 + "steps", 154 + "environment", 155 + "clone" 156 + ], 157 "properties": { 158 "name": { 159 "type": "string" 160 }, 161 "dependencies": { 162 + "type": "array", 163 + "items": { 164 + "type": "ref", 165 + "ref": "#dependency" 166 + } 167 }, 168 "steps": { 169 "type": "array", ··· 175 "environment": { 176 "type": "array", 177 "items": { 178 + "type": "ref", 179 + "ref": "#pair" 180 } 181 }, 182 "clone": { ··· 185 } 186 } 187 }, 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": { 201 "type": "string" 202 } 203 } 204 } 205 }, 206 "cloneOpts": { 207 "type": "object", 208 + "required": [ 209 + "skip", 210 + "depth", 211 + "submodules" 212 + ], 213 "properties": { 214 "skip": { 215 "type": "boolean" ··· 224 }, 225 "step": { 226 "type": "object", 227 + "required": [ 228 + "name", 229 + "command" 230 + ], 231 "properties": { 232 "name": { 233 "type": "string" ··· 238 "environment": { 239 "type": "array", 240 "items": { 241 + "type": "ref", 242 + "ref": "#pair" 243 } 244 } 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 + } 261 } 262 } 263 }
-147
knotserver/jetstream.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "net/url" 10 - "strings" 11 - 12 - "github.com/bluesky-social/jetstream/pkg/models" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/log" 16 - ) 17 - 18 - func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error { 19 - l := log.FromContext(ctx) 20 - pk := db.PublicKey{ 21 - Did: did, 22 - PublicKey: record, 23 - } 24 - if err := h.db.AddPublicKey(pk); err != nil { 25 - l.Error("failed to add public key", "error", err) 26 - return fmt.Errorf("failed to add public key: %w", err) 27 - } 28 - l.Info("added public key from firehose", "did", did) 29 - return nil 30 - } 31 - 32 - func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error { 33 - l := log.FromContext(ctx) 34 - 35 - if record.Domain != h.c.Server.Hostname { 36 - l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) 37 - return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 38 - } 39 - 40 - ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite") 41 - if err != nil || !ok { 42 - l.Error("failed to add member", "did", did) 43 - return fmt.Errorf("failed to enforce permissions: %w", err) 44 - } 45 - 46 - if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil { 47 - l.Error("failed to add member", "error", err) 48 - return fmt.Errorf("failed to add member: %w", err) 49 - } 50 - l.Info("added member from firehose", "member", record.Subject) 51 - 52 - if err := h.db.AddDid(did); err != nil { 53 - l.Error("failed to add did", "error", err) 54 - return fmt.Errorf("failed to add did: %w", err) 55 - } 56 - h.jc.AddDid(did) 57 - 58 - if err := h.fetchAndAddKeys(ctx, did); err != nil { 59 - return fmt.Errorf("failed to fetch and add keys: %w", err) 60 - } 61 - 62 - return nil 63 - } 64 - 65 - func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 66 - l := log.FromContext(ctx) 67 - 68 - keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) 69 - if err != nil { 70 - l.Error("error building endpoint url", "did", did, "error", err.Error()) 71 - return fmt.Errorf("error building endpoint url: %w", err) 72 - } 73 - 74 - resp, err := http.Get(keysEndpoint) 75 - if err != nil { 76 - l.Error("error getting keys", "did", did, "error", err) 77 - return fmt.Errorf("error getting keys: %w", err) 78 - } 79 - defer resp.Body.Close() 80 - 81 - if resp.StatusCode == http.StatusNotFound { 82 - l.Info("no keys found for did", "did", did) 83 - return nil 84 - } 85 - 86 - plaintext, err := io.ReadAll(resp.Body) 87 - if err != nil { 88 - l.Error("error reading response body", "error", err) 89 - return fmt.Errorf("error reading response body: %w", err) 90 - } 91 - 92 - for _, key := range strings.Split(string(plaintext), "\n") { 93 - if key == "" { 94 - continue 95 - } 96 - pk := db.PublicKey{ 97 - Did: did, 98 - } 99 - pk.Key = key 100 - if err := h.db.AddPublicKey(pk); err != nil { 101 - l.Error("failed to add public key", "error", err) 102 - return fmt.Errorf("failed to add public key: %w", err) 103 - } 104 - } 105 - return nil 106 - } 107 - 108 - func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 109 - did := event.Did 110 - if event.Kind != models.EventKindCommit { 111 - return nil 112 - } 113 - 114 - var err error 115 - defer func() { 116 - eventTime := event.TimeUS 117 - lastTimeUs := eventTime + 1 118 - fmt.Println("lastTimeUs", lastTimeUs) 119 - if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 120 - err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 121 - } 122 - }() 123 - 124 - raw := json.RawMessage(event.Commit.Record) 125 - 126 - switch event.Commit.Collection { 127 - case tangled.PublicKeyNSID: 128 - var record tangled.PublicKey 129 - if err := json.Unmarshal(raw, &record); err != nil { 130 - return fmt.Errorf("failed to unmarshal record: %w", err) 131 - } 132 - if err := h.processPublicKey(ctx, did, record); err != nil { 133 - return fmt.Errorf("failed to process public key: %w", err) 134 - } 135 - 136 - case tangled.KnotMemberNSID: 137 - var record tangled.KnotMember 138 - if err := json.Unmarshal(raw, &record); err != nil { 139 - return fmt.Errorf("failed to unmarshal record: %w", err) 140 - } 141 - if err := h.processKnotMember(ctx, did, record); err != nil { 142 - return fmt.Errorf("failed to process knot member: %w", err) 143 - } 144 - } 145 - 146 - return err 147 - }
···
+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
···
··· 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 + }
+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 + }
+26
types/diff.go
··· 5 "github.com/go-git/go-git/v5/plumbing/object" 6 ) 7 8 type TextFragment struct { 9 Header string `json:"comment"` 10 Lines []gitdiff.Line `json:"lines"` ··· 77 78 return files 79 }
··· 5 "github.com/go-git/go-git/v5/plumbing/object" 6 ) 7 8 + type DiffOpts struct { 9 + Split bool `json:"split"` 10 + } 11 + 12 type TextFragment struct { 13 Header string `json:"comment"` 14 Lines []gitdiff.Line `json:"lines"` ··· 81 82 return files 83 } 84 + 85 + // used by html elements as a unique ID for hrefs 86 + func (d *Diff) Id() string { 87 + return d.Name.New 88 + } 89 + 90 + func (d *Diff) Split() *SplitDiff { 91 + fragments := make([]SplitFragment, len(d.TextFragments)) 92 + for i, fragment := range d.TextFragments { 93 + leftLines, rightLines := SeparateLines(&fragment) 94 + fragments[i] = SplitFragment{ 95 + Header: fragment.Header(), 96 + LeftLines: leftLines, 97 + RightLines: rightLines, 98 + } 99 + } 100 + 101 + return &SplitDiff{ 102 + Name: d.Id(), 103 + TextFragments: fragments, 104 + } 105 + }
-11
appview/tid.go
··· 1 - package appview 2 - 3 - import ( 4 - "github.com/bluesky-social/indigo/atproto/syntax" 5 - ) 6 - 7 - var c syntax.TIDClock = syntax.NewTIDClock(0) 8 - 9 - func TID() string { 10 - return c.Next().String() 11 - }
···
-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
··· 4 5 // schema: sh.tangled.repo.issue.state.closed 6 7 - const () 8 9 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
··· 4 5 // schema: sh.tangled.repo.issue.state.closed 6 7 + const ( 8 + RepoIssueStateClosedNSID = "sh.tangled.repo.issue.state.closed" 9 + ) 10 11 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
+3 -1
api/tangled/stateopen.go
··· 4 5 // schema: sh.tangled.repo.issue.state.open 6 7 - const () 8 9 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
··· 4 5 // schema: sh.tangled.repo.issue.state.open 6 7 + const ( 8 + RepoIssueStateOpenNSID = "sh.tangled.repo.issue.state.open" 9 + ) 10 11 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
+3 -1
api/tangled/statusclosed.go
··· 4 5 // schema: sh.tangled.repo.pull.status.closed 6 7 - const () 8 9 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
··· 4 5 // schema: sh.tangled.repo.pull.status.closed 6 7 + const ( 8 + RepoPullStatusClosedNSID = "sh.tangled.repo.pull.status.closed" 9 + ) 10 11 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
+3 -1
api/tangled/statusmerged.go
··· 4 5 // schema: sh.tangled.repo.pull.status.merged 6 7 - const () 8 9 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
··· 4 5 // schema: sh.tangled.repo.pull.status.merged 6 7 + const ( 8 + RepoPullStatusMergedNSID = "sh.tangled.repo.pull.status.merged" 9 + ) 10 11 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
+3 -1
api/tangled/statusopen.go
··· 4 5 // schema: sh.tangled.repo.pull.status.open 6 7 - const () 8 9 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
··· 4 5 // schema: sh.tangled.repo.pull.status.open 6 7 + const ( 8 + RepoPullStatusOpenNSID = "sh.tangled.repo.pull.status.open" 9 + ) 10 11 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
+2 -3
appview/idresolver/resolver.go idresolver/resolver.go
··· 11 "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "github.com/carlmjohnson/versioninfo" 14 - "tangled.sh/tangled.sh/core/appview/config" 15 ) 16 17 type Resolver struct { ··· 56 } 57 } 58 59 - func RedisResolver(config config.RedisConfig) (*Resolver, error) { 60 - directory, err := RedisDirectory(config.ToURL()) 61 if err != nil { 62 return nil, err 63 }
··· 11 "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "github.com/carlmjohnson/versioninfo" 14 ) 15 16 type Resolver struct { ··· 55 } 56 } 57 58 + func RedisResolver(redisUrl string) (*Resolver, error) { 59 + directory, err := RedisDirectory(redisUrl) 60 if err != nil { 61 return nil, err 62 }
+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
···
··· 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 + }
+4 -3
docs/spindle/architecture.md
··· 13 14 ### the engine 15 16 - At present, the only supported backend is Docker. Spindle executes each step in 17 - the pipeline in a fresh container, with state persisted across steps within the 18 - `/tangled/workspace` directory. 19 20 The base image for the container is constructed on the fly using 21 [Nixery](https://nixery.dev), which is handy for caching layers for frequently
··· 13 14 ### the engine 15 16 + At present, the only supported backend is Docker (and Podman, if Docker 17 + compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 + executes each step in the pipeline in a fresh container, with state persisted 19 + across steps within the `/tangled/workspace` directory. 20 21 The base image for the container is constructed on the fly using 22 [Nixery](https://nixery.dev), which is handy for caching layers for frequently
+6 -1
hook/setup.go
··· 133 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 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 137 `, executablePath, config.internalApi) 138 139 return os.WriteFile(hookPath, []byte(hookContent), 0755)
··· 133 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 135 # AUTO GENERATED BY KNOT, DO NOT MODIFY 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 142 `, executablePath, config.internalApi) 143 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+14
hook/hook.go
··· 3 import ( 4 "bufio" 5 "context" 6 "fmt" 7 "net/http" 8 "os" ··· 11 "github.com/urfave/cli/v3" 12 ) 13 14 // The hook command is nested like so: 15 // 16 // knot hook --[flags] [hook] ··· 88 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 89 } 90 91 return nil 92 }
··· 3 import ( 4 "bufio" 5 "context" 6 + "encoding/json" 7 "fmt" 8 "net/http" 9 "os" ··· 12 "github.com/urfave/cli/v3" 13 ) 14 15 + type HookResponse struct { 16 + Messages []string `json:"messages"` 17 + } 18 + 19 // The hook command is nested like so: 20 // 21 // knot hook --[flags] [hook] ··· 93 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 94 } 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 + 105 return nil 106 }
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 9 "github.com/yuin/goldmark/ast" 10 ) 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 13 h := hmac.New(sha256.New, []byte(secret)) 14 h.Write([]byte(imageURL)) 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 } 25 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 } 29 30 return dst
··· 9 "github.com/yuin/goldmark/ast" 10 ) 11 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 h := hmac.New(sha256.New, []byte(secret)) 14 h.Write([]byte(imageURL)) 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 } 25 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 } 29 30 return dst
+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/artifact.json lexicons/repo/artifact.json
lexicons/defaultBranch.json lexicons/repo/defaultBranch.json
lexicons/listSecrets.json lexicons/repo/listSecrets.json
lexicons/removeSecret.json lexicons/repo/removeSecret.json
lexicons/spindle.json lexicons/spindle/spindle.json
+25
api/tangled/repocollaborator.go
···
··· 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
···
··· 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
···
··· 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
···
··· 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 + }
+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
nix/pkgs/lexgen.nix
··· 7 version = "0.1.0"; 8 src = indigo; 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 11 doCheck = false; 12 }
··· 7 version = "0.1.0"; 8 src = indigo; 9 subPackages = ["cmd/lexgen"]; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 doCheck = false; 12 }
+1 -1
spindle/secrets/openbao.go
··· 132 return ErrKeyNotFound 133 } 134 135 - err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 136 if err != nil { 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 }
··· 132 return ErrKeyNotFound 133 } 134 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 136 if err != nil { 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 }
+3 -14
nix/pkgs/appview.nix
··· 1 { 2 buildGoApplication, 3 modules, 4 - htmx-src, 5 - htmx-ws-src, 6 - lucide-src, 7 - inter-fonts-src, 8 - ibm-plex-mono-src, 9 - tailwindcss, 10 sqlite-lib, 11 src, 12 }: ··· 17 18 postUnpack = '' 19 pushd source 20 - mkdir -p appview/pages/static/{fonts,icons} 21 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 22 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 23 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 24 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 25 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 26 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 27 - ${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 28 popd 29 ''; 30
··· 1 { 2 buildGoApplication, 3 modules, 4 + appview-static-files, 5 sqlite-lib, 6 src, 7 }: ··· 12 13 postUnpack = '' 14 pushd source 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 17 popd 18 ''; 19
-168
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - 3 - {{ define "repoContent" }} 4 - {{ template "collaboratorSettings" . }} 5 - {{ template "branchSettings" . }} 6 - {{ template "dangerZone" . }} 7 - {{ template "spindleSelector" . }} 8 - {{ template "spindleSecrets" . }} 9 - {{ end }} 10 - 11 - {{ define "collaboratorSettings" }} 12 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 - Collaborators 14 - </header> 15 - 16 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 17 - {{ range .Collaborators }} 18 - <div id="collaborator" class="mb-2"> 19 - <a 20 - href="/{{ didOrHandle .Did .Handle }}" 21 - class="no-underline hover:underline text-black dark:text-white" 22 - > 23 - {{ didOrHandle .Did .Handle }} 24 - </a> 25 - <div> 26 - <span class="text-sm text-gray-500 dark:text-gray-400"> 27 - {{ .Role }} 28 - </span> 29 - </div> 30 - </div> 31 - {{ end }} 32 - </div> 33 - 34 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 35 - <form 36 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 - class="group" 38 - > 39 - <label for="collaborator" class="dark:text-white"> 40 - add collaborator 41 - </label> 42 - <input 43 - type="text" 44 - id="collaborator" 45 - name="collaborator" 46 - required 47 - class="dark:bg-gray-700 dark:text-white" 48 - placeholder="enter did or handle"> 49 - <button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text"> 50 - <span>add</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </button> 53 - </form> 54 - {{ end }} 55 - {{ end }} 56 - 57 - {{ define "dangerZone" }} 58 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 59 - <form 60 - hx-confirm="Are you sure you want to delete this repository?" 61 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 62 - class="mt-6" 63 - hx-indicator="#delete-repo-spinner"> 64 - <label for="branch">delete repository</label> 65 - <button class="btn my-2 flex items-center" type="text"> 66 - <span>delete</span> 67 - <span id="delete-repo-spinner" class="group"> 68 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 69 - </span> 70 - </button> 71 - <span> 72 - Deleting a repository is irreversible and permanent. 73 - </span> 74 - </form> 75 - {{ end }} 76 - {{ end }} 77 - 78 - {{ define "branchSettings" }} 79 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group"> 80 - <label for="branch">default branch</label> 81 - <div class="flex gap-2 items-center"> 82 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 83 - <option value="" disabled selected > 84 - Choose a default branch 85 - </option> 86 - {{ range .Branches }} 87 - <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 88 - {{ .Name }} 89 - </option> 90 - {{ end }} 91 - </select> 92 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 93 - <span>save</span> 94 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 95 - </button> 96 - </div> 97 - </form> 98 - {{ end }} 99 - 100 - {{ define "spindleSelector" }} 101 - {{ if .RepoInfo.Roles.IsOwner }} 102 - <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" > 103 - <label for="spindle">spindle</label> 104 - <div class="flex gap-2 items-center"> 105 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 106 - <option value="" selected > 107 - None 108 - </option> 109 - {{ range .Spindles }} 110 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 111 - {{ . }} 112 - </option> 113 - {{ end }} 114 - </select> 115 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 116 - <span>save</span> 117 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 118 - </button> 119 - </div> 120 - </form> 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "spindleSecrets" }} 125 - {{ if $.CurrentSpindle }} 126 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 127 - Secrets 128 - </header> 129 - 130 - <div id="secret-list" class="flex flex-col gap-2 mb-2"> 131 - {{ range $idx, $secret := .Secrets }} 132 - {{ with $secret }} 133 - <div id="secret-{{$idx}}" class="mb-2"> 134 - {{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }} 135 - </div> 136 - {{ end }} 137 - {{ end }} 138 - </div> 139 - <form 140 - hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 141 - class="mt-6" 142 - hx-indicator="#add-secret-spinner"> 143 - <label for="key">secret key</label> 144 - <input 145 - type="text" 146 - id="key" 147 - name="key" 148 - required 149 - class="dark:bg-gray-700 dark:text-white" 150 - placeholder="SECRET_KEY" /> 151 - <label for="value">secret value</label> 152 - <input 153 - type="text" 154 - id="value" 155 - name="value" 156 - required 157 - class="dark:bg-gray-700 dark:text-white" 158 - placeholder="SECRET VALUE" /> 159 - 160 - <button class="btn my-2 flex items-center" type="text"> 161 - <span>add</span> 162 - <span id="add-secret-spinner" class="group"> 163 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 164 - </span> 165 - </button> 166 - </form> 167 - {{ end }} 168 - {{ end }}
···
-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
··· 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;
···
+2 -1
.gitignore
··· 16 *.rdb 17 .envrc 18 # Created if following hacking.md 19 - genjwks.out
··· 16 *.rdb 17 .envrc 18 # Created if following hacking.md 19 + genjwks.out 20 + /nix/vm-data
+1 -1
.air/appview.toml
··· 5 6 exclude_regex = [".*_templ.go"] 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"]
··· 5 6 exclude_regex = [".*_templ.go"] 7 include_ext = ["go", "templ", "html", "css"] 8 + exclude_dir = ["target", "atrium", "nix"]
+1 -1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
··· 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 {{ else if eq $kind "timeout" }} 21 {{ $icon = "clock-alert" }} 22 - {{ $color = "text-orange-400 dark:text-orange-300" }} 23 {{ else }} 24 {{ $icon = "x" }} 25 {{ $color = "text-red-600 dark:text-red-500" }}
··· 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 {{ else if eq $kind "timeout" }} 21 {{ $icon = "clock-alert" }} 22 + {{ $color = "text-orange-400 dark:text-orange-500" }} 23 {{ else }} 24 {{ $icon = "x" }} 25 {{ $color = "text-red-600 dark:text-red-500" }}
+1 -1
eventconsumer/cursor/sqlite.go
··· 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 }
··· 21 } 22 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 25 if err != nil { 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 }
+1 -1
spindle/secrets/sqlite.go
··· 24 } 25 26 func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 - db, err := sql.Open("sqlite3", dbPath) 28 if err != nil { 29 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 }
··· 24 } 25 26 func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 28 if err != nil { 29 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 }
+14 -9
spindle/db/db.go
··· 2 3 import ( 4 "database/sql" 5 6 _ "github.com/mattn/go-sqlite3" 7 ) ··· 11 } 12 13 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 15 if err != nil { 16 return nil, err 17 } 18 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; 27 28 create table if not exists _jetstream ( 29 id integer primary key autoincrement, 30 last_time_us integer not null
··· 2 3 import ( 4 "database/sql" 5 + "strings" 6 7 _ "github.com/mattn/go-sqlite3" 8 ) ··· 12 } 13 14 func Make(dbPath string) (*DB, error) { 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, "&")) 24 if err != nil { 25 return nil, err 26 } 27 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. 31 32 + _, err = db.Exec(` 33 create table if not exists _jetstream ( 34 id integer primary key autoincrement, 35 last_time_us integer not null
+12
.prettierrc.json
···
··· 1 + { 2 + "overrides": [ 3 + { 4 + "files": ["*.html"], 5 + "options": { 6 + "parser": "go-template" 7 + } 8 + } 9 + ], 10 + "bracketSameLine": true, 11 + "htmlWhitespaceSensitivity": "ignore" 12 + }
-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 - }
···
+37 -83
appview/pages/templates/layouts/footer.html
··· 1 {{ define "layouts/footer" }} 2 - <div 3 - class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 4 - <div class="container mx-auto max-w-7xl px-4"> 5 - <div 6 - class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 7 - <div class="mb-4 md:mb-0"> 8 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 9 - tangled 10 - <sub>alpha</sub> 11 - </a> 12 - </div> 13 - 14 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 15 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 - <div 18 - class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 19 - <div class="flex flex-col gap-1"> 20 - <div class="{{ $headerStyle }}">legal</div> 21 - <a href="/terms" class="{{ $linkStyle }}"> 22 - {{ i "file-text" $iconStyle }} terms of service 23 - </a> 24 - <a href="/privacy" class="{{ $linkStyle }}"> 25 - {{ i "shield" $iconStyle }} privacy policy 26 - </a> 27 - </div> 28 29 - <div class="flex flex-col gap-1"> 30 - <div class="{{ $headerStyle }}">resources</div> 31 - <a 32 - href="https://blog.tangled.sh" 33 - class="{{ $linkStyle }}" 34 - target="_blank" 35 - rel="noopener noreferrer"> 36 - {{ i "book-open" $iconStyle }} blog 37 - </a> 38 - <a 39 - href="https://tangled.sh/@tangled.sh/core/tree/master/docs" 40 - class="{{ $linkStyle }}"> 41 - {{ i "book" $iconStyle }} docs 42 - </a> 43 - <a 44 - href="https://tangled.sh/@tangled.sh/core" 45 - class="{{ $linkStyle }}"> 46 - {{ i "code" $iconStyle }} source 47 - </a> 48 - </div> 49 50 - <div class="flex flex-col gap-1"> 51 - <div class="{{ $headerStyle }}">social</div> 52 - <a 53 - href="https://chat.tangled.sh" 54 - class="{{ $linkStyle }}" 55 - target="_blank" 56 - rel="noopener noreferrer"> 57 - {{ i "message-circle" $iconStyle }} discord 58 - </a> 59 - <a 60 - href="https://web.libera.chat/#tangled" 61 - class="{{ $linkStyle }}" 62 - target="_blank" 63 - rel="noopener noreferrer"> 64 - {{ i "hash" $iconStyle }} irc 65 - </a> 66 - <a 67 - href="https://bsky.app/profile/tangled.sh" 68 - class="{{ $linkStyle }}" 69 - target="_blank" 70 - rel="noopener noreferrer"> 71 - {{ template "user/fragments/bluesky" $iconStyle }} bluesky 72 - </a> 73 - </div> 74 75 - <div class="flex flex-col gap-1"> 76 - <div class="{{ $headerStyle }}">contact</div> 77 - <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}"> 78 - {{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh 79 - </a> 80 - <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}"> 81 - {{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh 82 - </a> 83 - </div> 84 </div> 85 86 - <div class="text-center lg:text-right flex-shrink-0"> 87 - <div class="text-xs"> 88 - &copy; 2025 Tangled Labs Oy. All rights reserved. 89 - </div> 90 </div> 91 </div> 92 </div> 93 </div> 94 {{ end }}
··· 1 {{ define "layouts/footer" }} 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 </div> 34 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 </div> 40 </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 45 </div> 46 </div> 47 + </div> 48 {{ end }}
+9 -16
appview/pages/templates/repo/compare/new.html
··· 1 {{ define "title" }} 2 - compare refs on 3 - {{ .RepoInfo.FullName }} 4 {{ end }} 5 6 {{ define "repoContent" }} ··· 10 {{ define "repoAfter" }} 11 {{ $brs := take .Branches 5 }} 12 {{ if $brs }} 13 - <section 14 - class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto"> 15 <div class="flex flex-col items-center"> 16 <p class="text-center text-black dark:text-white"> 17 - Recently updated branches in this repository: 18 </p> 19 - <div 20 - class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2"> 21 - {{ range $br := $brs }} 22 - <a 23 - href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" 24 - class="no-underline hover:no-underline"> 25 <div class="flex items-center justify-between p-2"> 26 {{ $br.Name }} 27 - <span class="text-gray-500 dark:text-gray-400"> 28 - {{ template "repo/fragments/time" $br.Commit.Committer.When }} 29 - </span> 30 </div> 31 </a> 32 - {{ end }} 33 - </div> 34 </div> 35 </section> 36 {{ end }}
··· 1 {{ define "title" }} 2 + compare refs on {{ .RepoInfo.FullName }} 3 {{ end }} 4 5 {{ define "repoContent" }} ··· 9 {{ define "repoAfter" }} 10 {{ $brs := take .Branches 5 }} 11 {{ if $brs }} 12 + <section class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto"> 13 <div class="flex flex-col items-center"> 14 <p class="text-center text-black dark:text-white"> 15 + Recently updated branches in this repository: 16 </p> 17 + <div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2"> 18 + {{ range $br := $brs }} 19 + <a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline"> 20 <div class="flex items-center justify-between p-2"> 21 {{ $br.Name }} 22 + <span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span> 23 </div> 24 </a> 25 + {{ end }} 26 + </div> 27 </div> 28 </section> 29 {{ end }}
+13 -27
appview/pages/templates/repo/fragments/artifact.html
··· 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> 17 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> 27 28 <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> 32 33 {{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }} 34 <button
··· 1 {{ define "repo/fragments/artifact" }} 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> 11 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> 15 16 <span class="select-none after:content-['·'] hidden md:inline"></span> 17 + <span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span> 18 19 {{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }} 20 <button
+6 -14
appview/pages/templates/repo/fragments/diffOpts.html
··· 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"> 4 <strong class="text-sm uppercase dark:text-gray-200">options</strong> 5 {{ $active := "unified" }} 6 {{ if .Split }} 7 {{ $active = "split" }} 8 {{ end }} 9 {{ $values := list "unified" "split" }} 10 - {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} 11 - {{ end }} 12 </section> 13 {{ end }} 14 ··· 16 {{ $name := .Name }} 17 {{ $all := .Values }} 18 {{ $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"> 21 {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 22 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 23 {{ range $index, $value := $all }} 24 {{ $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 }} 33 </a> 34 {{ end }} 35 </div>
··· 1 {{ define "repo/fragments/diffOpts" }} 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"> 3 <strong class="text-sm uppercase dark:text-gray-200">options</strong> 4 {{ $active := "unified" }} 5 {{ if .Split }} 6 {{ $active = "split" }} 7 {{ end }} 8 {{ $values := list "unified" "split" }} 9 + {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 10 </section> 11 {{ end }} 12 ··· 14 {{ $name := .Name }} 15 {{ $all := .Values }} 16 {{ $active := .Active }} 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"> 18 {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 19 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 20 {{ range $index, $value := $all }} 21 {{ $isActive := eq $value $active }} 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 }} 25 </a> 26 {{ end }} 27 </div>
+5 -16
appview/pages/templates/repo/fragments/diffStatPill.html
··· 1 {{ define "repo/fragments/diffStatPill" }} 2 <div class="flex items-center font-mono text-sm"> 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> 12 {{ 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> 17 {{ 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> 22 {{ end }} 23 </div> 24 {{ end }}
··· 1 {{ define "repo/fragments/diffStatPill" }} 2 <div class="flex items-center font-mono text-sm"> 3 {{ if and .Insertions .Deletions }} 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> 6 {{ else if .Insertions }} 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> 8 {{ else if .Deletions }} 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> 10 {{ end }} 11 </div> 12 {{ end }} 13 +
+24 -18
appview/pages/templates/repo/fragments/reactionsPopUp.html
··· 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 5 hover:bg-gray-50 6 hover:border-gray-300 7 dark:hover:bg-gray-700 8 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> 24 {{ end }}
··· 1 {{ define "repo/fragments/reactionsPopUp" }} 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 8 hover:bg-gray-50 9 hover:border-gray-300 10 dark:hover:bg-gray-700 11 dark:hover:border-gray-600 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> 30 {{ end }}
+23 -22
appview/pages/templates/repo/fragments/repoStar.html
··· 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 }} 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> 25 {{ end }}
··· 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 }} 10 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> 26 {{ end }}
+58 -113
appview/pages/templates/repo/fragments/splitDiff.html
··· 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 - &middot;&middot;&middot; 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> 64 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 - &middot;&middot;&middot; 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> 116 {{ end }}
··· 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 class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 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> 36 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">&middot;&middot;&middot;</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> 61 {{ end }}
+45 -79
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 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 - &middot;&middot;&middot; 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> 81 {{ end }}
··· 1 {{ define "repo/fragments/unifiedDiff" }} 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">&middot;&middot;&middot;</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> 46 {{ end }} 47 +
+2 -3
appview/pages/templates/repo/pipelines/fragments/logLine.html
··· 1 {{ define "repo/pipelines/fragments/logLine" }} 2 - <div id="step-body-{{ .Id }}" hx-swap-oob="beforeend" class="whitespace-pre"> 3 - <p>{{ .Content }}</p> 4 - </div> 5 {{ end }}
··· 1 {{ define "repo/pipelines/fragments/logLine" }} 2 + <div id="step-body-{{ .Id }}" hx-swap-oob="beforeend" class="whitespace-pre"><p>{{ .Content }}</p></div> 3 {{ end }} 4 +
+9 -22
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 37 {{ $offset := 0.0 }} 38 <div class="flex gap-1 items-center"> 39 <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 40 - <circle 41 - cx="10" 42 - cy="10" 43 - r="{{ $radius }}" 44 - fill="none" 45 - stroke="#f3f4f633" 46 - stroke-width="2" /> 47 48 {{ range $kind, $count := $c }} 49 {{ $color := "" }} 50 {{ if or (eq $kind "pending") (eq $kind "running") }} 51 - {{ $color = "#eab308" }} 52 - {{/* amber-500 */}} 53 {{ else if eq $kind "success" }} 54 - {{ $color = "#10b981" }} 55 - {{/* green-500 */}} 56 {{ else if eq $kind "cancelled" }} 57 - {{ $color = "#6b7280" }} 58 - {{/* gray-500 */}} 59 {{ else if eq $kind "timeout" }} 60 - {{ $color = "#fb923c" }} 61 - {{/* orange-400 */}} 62 {{ else }} 63 - {{ $color = "#ef4444" }} 64 - {{/* red-500 for failed or unknown */}} 65 {{ end }} 66 67 {{ $percent := divf64 (f64 $count) (f64 $total) }} 68 {{ $length := mulf64 $percent $circumference }} 69 70 - 71 <circle 72 - cx="10" 73 - cy="10" 74 - r="{{ $radius }}" 75 fill="none" 76 stroke="{{ $color }}" 77 stroke-width="2" 78 stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 79 - stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" /> 80 {{ $offset = addf64 $offset $length }} 81 {{ end }} 82 </svg>
··· 37 {{ $offset := 0.0 }} 38 <div class="flex gap-1 items-center"> 39 <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 40 + <circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/> 41 42 {{ range $kind, $count := $c }} 43 {{ $color := "" }} 44 {{ if or (eq $kind "pending") (eq $kind "running") }} 45 + {{ $color = "#eab308" }} {{/* amber-500 */}} 46 {{ else if eq $kind "success" }} 47 + {{ $color = "#10b981" }} {{/* green-500 */}} 48 {{ else if eq $kind "cancelled" }} 49 + {{ $color = "#6b7280" }} {{/* gray-500 */}} 50 {{ else if eq $kind "timeout" }} 51 + {{ $color = "#fb923c" }} {{/* orange-400 */}} 52 {{ else }} 53 + {{ $color = "#ef4444" }} {{/* red-500 for failed or unknown */}} 54 {{ end }} 55 56 {{ $percent := divf64 (f64 $count) (f64 $total) }} 57 {{ $length := mulf64 $percent $circumference }} 58 59 <circle 60 + cx="10" cy="10" r="{{ $radius }}" 61 fill="none" 62 stroke="{{ $color }}" 63 stroke-width="2" 64 stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 65 + stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 66 + /> 67 {{ $offset = addf64 $offset $length }} 68 {{ end }} 69 </svg>
+146 -136
appview/pages/templates/repo/pulls/new.html
··· 1 {{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }} 2 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 - <!-- 78 since compare-forks need the server to load forks, we 79 hx-get this button; unlike simply loading the pullCompareForks template 80 as we do for the rest of the gang below. the hx-vals thing just populates 81 the query params so the forks page gets it. 82 --> 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> 145 {{ end }}
··· 1 {{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }} 2 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 + > 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 + <!-- 82 since compare-forks need the server to load forks, we 83 hx-get this button; unlike simply loading the pullCompareForks template 84 as we do for the rest of the gang below. the hx-vals thing just populates 85 the query params so the forks page gets it. 86 --> 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> 155 {{ end }}
+8 -15
appview/pages/templates/repo/settings/fragments/secretListing.html
··· 1 {{ define "repo/settings/fragments/secretListing" }} 2 {{ $root := index . 0 }} 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%]"> 9 <span class="font-mono"> 10 {{ $secret.Key }} 11 </span> 12 - <div 13 - class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 14 <span>added by</span> 15 - <span> 16 - {{ template "user/fragments/picHandleLink" $secret.CreatedBy }} 17 - </span> 18 <span class="before:content-['·'] before:select-none"></span> 19 - <span> 20 - {{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }} 21 - </span> 22 </div> 23 </div> 24 <button ··· 27 hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets" 28 hx-swap="none" 29 hx-vals='{"key": "{{ $secret.Key }}"}' 30 - hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?"> 31 {{ i "trash-2" "w-5 h-5" }} 32 - <span class="hidden md:inline">delete</span> 33 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 34 </button> 35 </div>
··· 1 {{ define "repo/settings/fragments/secretListing" }} 2 {{ $root := index . 0 }} 3 {{ $secret := index . 1 }} 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%]"> 6 <span class="font-mono"> 7 {{ $secret.Key }} 8 </span> 9 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 10 <span>added by</span> 11 + <span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span> 12 <span class="before:content-['·'] before:select-none"></span> 13 + <span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span> 14 </div> 15 </div> 16 <button ··· 19 hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets" 20 hx-swap="none" 21 hx-vals='{"key": "{{ $secret.Key }}"}' 22 + hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?" 23 + > 24 {{ i "trash-2" "w-5 h-5" }} 25 + <span class="hidden md:inline">delete</span> 26 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 </button> 28 </div>
+7 -15
appview/pages/templates/repo/settings/fragments/sidebar.html
··· 1 {{ define "repo/settings/fragments/sidebar" }} 2 {{ $active := .Tab }} 3 {{ $tabs := .Tabs }} 4 - <div 5 - class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 6 {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 7 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 8 {{ range $tabs }} 9 - <a 10 - href="/{{ $.RepoInfo.FullName }}/settings?tab={{ .Name }}" 11 - class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 12 - <div 13 - class="flex gap-3 items-center p-2 {{ if eq .Name $active }} 14 - {{ $activeTab }} 15 - {{ else }} 16 - {{ $inactiveTab }} 17 - {{ end }}"> 18 - {{ i .Icon "size-4" }} 19 - {{ .Name }} 20 - </div> 21 - </a> 22 {{ end }} 23 </div> 24 {{ end }}
··· 1 {{ define "repo/settings/fragments/sidebar" }} 2 {{ $active := .Tab }} 3 {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 {{ range $tabs }} 8 + <a href="/{{ $.RepoInfo.FullName }}/settings?tab={{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 {{ end }} 15 </div> 16 {{ end }}
+51 -51
appview/pages/templates/spindles/dashboard.html
··· 1 - {{ define "title" }}{{ .Spindle.Instance }} &middot; spindles{{ end }} 2 3 {{ define "content" }} 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">{{ .Spindle.Instance }}</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 .Spindle.Owner) }} 10 - {{ if .Spindle.Verified }} 11 - <span 12 - class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{ $style }}"> 13 - {{ i "shield-check" "w-4 h-4" }} verified 14 - </span> 15 - {{ if $isOwner }} 16 - {{ template "spindles/fragments/addMemberModal" .Spindle }} 17 - {{ end }} 18 - {{ else }} 19 - <span 20 - class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{ $style }}"> 21 - {{ i "shield-off" "w-4 h-4" }} unverified 22 - </span> 23 - {{ if $isOwner }} 24 - {{ block "retryButton" .Spindle }}{{ end }} 25 - {{ end }} 26 {{ end }} 27 - 28 {{ if $isOwner }} 29 - {{ block "deleteButton" .Spindle }}{{ end }} 30 {{ end }} 31 - </div> 32 </div> 33 - <div id="operation-error" class="dark:text-red-400"></div> 34 </div> 35 36 - {{ if .Members }} 37 - <section 38 - 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 }} 44 {{ end }} 45 46 {{ define "member" }} 47 {{ range .Members }} 48 <div> ··· 51 {{ template "user/fragments/picHandleLink" . }} 52 </div> 53 {{ if ne $.LoggedInUser.Did . }} 54 - {{ block "removeMemberButton" (list $ . ) }}{{ end }} 55 {{ end }} 56 </div> 57 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> ··· 80 hx-delete="/spindles/{{ .Instance }}" 81 hx-swap="outerHTML" 82 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 83 - hx-headers='{"shouldRedirect": "true"}'> 84 {{ i "trash-2" "w-5 h-5" }} 85 - <span class="hidden md:inline">delete</span> 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 </button> 88 {{ end }} 89 90 {{ define "retryButton" }} 91 <button 92 class="btn gap-2 group" 93 title="Retry spindle verification" 94 hx-post="/spindles/{{ .Instance }}/retry" 95 hx-swap="none" 96 - hx-headers='{"shouldRefresh": "true"}'> 97 {{ i "rotate-ccw" "w-5 h-5" }} 98 - <span class="hidden md:inline">retry</span> 99 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 </button> 101 {{ end }} 102 103 {{ define "removeMemberButton" }} 104 {{ $root := index . 0 }} 105 {{ $member := index . 1 }} 106 - <button 107 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 108 - title="Remove member" 109 - hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 110 - hx-swap="none" 111 - hx-vals='{"member": "{{ $member }}" }' 112 - hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"> 113 - {{ i "user-minus" "w-4 h-4" }} 114 - remove 115 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 116 - </button> 117 {{ end }}
··· 1 + {{ define "title" }}{{.Spindle.Instance}} &middot; spindles{{ end }} 2 3 {{ define "content" }} 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">{{ .Spindle.Instance }}</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 .Spindle.Owner) }} 10 + {{ if .Spindle.Verified }} 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 }} 13 + {{ template "spindles/fragments/addMemberModal" .Spindle }} 14 {{ end }} 15 + {{ else }} 16 + <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> 17 {{ if $isOwner }} 18 + {{ block "retryButton" .Spindle }} {{ end }} 19 {{ end }} 20 + {{ end }} 21 + 22 + {{ if $isOwner }} 23 + {{ block "deleteButton" .Spindle }} {{ end }} 24 + {{ end }} 25 </div> 26 </div> 27 + <div id="operation-error" class="dark:text-red-400"></div> 28 + </div> 29 30 + {{ if .Members }} 31 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 32 + <div class="flex flex-col gap-2"> 33 + {{ block "member" . }} {{ end }} 34 + </div> 35 + </section> 36 + {{ end }} 37 {{ end }} 38 39 + 40 {{ define "member" }} 41 {{ range .Members }} 42 <div> ··· 45 {{ template "user/fragments/picHandleLink" . }} 46 </div> 47 {{ if ne $.LoggedInUser.Did . }} 48 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 49 {{ end }} 50 </div> 51 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> ··· 74 hx-delete="/spindles/{{ .Instance }}" 75 hx-swap="outerHTML" 76 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 77 + hx-headers='{"shouldRedirect": "true"}' 78 + > 79 {{ i "trash-2" "w-5 h-5" }} 80 + <span class="hidden md:inline">delete</span> 81 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 </button> 83 {{ end }} 84 85 + 86 {{ define "retryButton" }} 87 <button 88 class="btn gap-2 group" 89 title="Retry spindle verification" 90 hx-post="/spindles/{{ .Instance }}/retry" 91 hx-swap="none" 92 + hx-headers='{"shouldRefresh": "true"}' 93 + > 94 {{ i "rotate-ccw" "w-5 h-5" }} 95 + <span class="hidden md:inline">retry</span> 96 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 97 </button> 98 {{ end }} 99 100 + 101 {{ define "removeMemberButton" }} 102 {{ $root := index . 0 }} 103 {{ $member := index . 1 }} 104 + <button 105 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 106 + title="Remove member" 107 + hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 108 + hx-swap="none" 109 + hx-vals='{"member": "{{$member}}" }' 110 + hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?" 111 + > 112 + {{ i "user-minus" "w-4 h-4" }} 113 + remove 114 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 115 + </button> 116 {{ end }} 117 +
+84 -119
appview/pages/templates/timeline.html
··· 1 {{ define "title" }}timeline{{ end }} 2 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" /> 8 {{ end }} 9 10 {{ define "topbar" }} ··· 12 {{ end }} 13 14 {{ define "content" }} 15 - {{ with .LoggedInUser }} 16 - {{ block "timeline" $ }}{{ end }} 17 - {{ else }} 18 - {{ block "hero" $ }}{{ end }} 19 - {{ block "timeline" $ }}{{ end }} 20 - {{ end }} 21 {{ end }} 22 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> 30 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> 41 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> 49 </div> 50 - </div> 51 {{ end }} 52 53 {{ define "timeline" }} 54 - <div> 55 - <div class="p-6"> 56 - <p class="text-xl font-bold dark:text-white">Timeline</p> 57 - </div> 58 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 }} 76 {{ end }} 77 </div> 78 {{ end }} 79 </div> 80 - {{ end }} 81 </div> 82 - </div> 83 {{ end }} 84 85 {{ define "repoEvent" }} ··· 87 {{ $repo := index . 1 }} 88 {{ $source := index . 2 }} 89 {{ $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> 119 {{ with $repo }} 120 {{ template "user/fragments/repoCard" (list $root . true) }} 121 {{ end }} ··· 127 {{ with $star }} 128 {{ $starrerHandle := resolve .StarredByDid }} 129 {{ $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> 142 </div> 143 {{ with .Repo }} 144 {{ template "user/fragments/repoCard" (list $root . true) }} ··· 146 {{ end }} 147 {{ end }} 148 149 {{ define "followEvent" }} 150 {{ $root := index . 0 }} 151 {{ $follow := index . 1 }} ··· 154 155 {{ $userHandle := resolve $follow.UserDid }} 156 {{ $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> 165 </div> 166 - <div 167 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 168 <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 }}" /> 172 </div> 173 174 <div class="flex-1 min-h-0 justify-around flex flex-col"> 175 <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> 180 </a> 181 {{ with $profile }} 182 {{ with .Description }} 183 - <p class="text-sm pb-2 md:pb-2">{{ . }}</p> 184 {{ end }} 185 {{ end }} 186 {{ 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"> 189 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 190 <span id="followers">{{ .Followers }} followers</span> 191 <span class="select-none after:content-['·']"></span> 192 - <span id="following">{{ .Following }} following</span> 193 </div> 194 {{ end }} 195 </div>
··· 1 {{ define "title" }}timeline{{ end }} 2 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" /> 8 {{ end }} 9 10 {{ define "topbar" }} ··· 12 {{ end }} 13 14 {{ define "content" }} 15 + {{ with .LoggedInUser }} 16 + {{ block "timeline" $ }}{{ end }} 17 + {{ else }} 18 + {{ block "hero" $ }}{{ end }} 19 + {{ block "timeline" $ }}{{ end }} 20 + {{ end }} 21 {{ end }} 22 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">tightly-knit<br>social coding.</div> 26 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> 35 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> 43 </div> 44 {{ end }} 45 46 {{ define "timeline" }} 47 + <div> 48 + <div class="p-6"> 49 + <p class="text-xl font-bold dark:text-white">Timeline</p> 50 + </div> 51 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> 68 {{ end }} 69 </div> 70 {{ end }} 71 </div> 72 </div> 73 {{ end }} 74 75 {{ define "repoEvent" }} ··· 77 {{ $repo := index . 1 }} 78 {{ $source := index . 2 }} 79 {{ $userHandle := resolve $repo.Did }} 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> 98 {{ with $repo }} 99 {{ template "user/fragments/repoCard" (list $root . true) }} 100 {{ end }} ··· 106 {{ with $star }} 107 {{ $starrerHandle := resolve .StarredByDid }} 108 {{ $repoOwnerHandle := resolve .Repo.Did }} 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> 116 </div> 117 {{ with .Repo }} 118 {{ template "user/fragments/repoCard" (list $root . true) }} ··· 120 {{ end }} 121 {{ end }} 122 123 + 124 {{ define "followEvent" }} 125 {{ $root := index . 0 }} 126 {{ $follow := index . 1 }} ··· 129 130 {{ $userHandle := resolve $follow.UserDid }} 131 {{ $subjectHandle := resolve $follow.SubjectDid }} 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> 137 </div> 138 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 139 <div class="flex-shrink-0 max-h-full w-24 h-24"> 140 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 141 </div> 142 143 <div class="flex-1 min-h-0 justify-around flex flex-col"> 144 <a href="/{{ $subjectHandle }}"> 145 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 146 </a> 147 {{ with $profile }} 148 {{ with .Description }} 149 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 150 {{ end }} 151 {{ end }} 152 {{ with $stat }} 153 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 154 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 155 <span id="followers">{{ .Followers }} followers</span> 156 <span class="select-none after:content-['·']"></span> 157 + <span id="following">{{ .Following }} following</span> 158 </div> 159 {{ end }} 160 </div>
+29 -48
appview/pages/templates/user/fragments/editPins.html
··· 1 {{ define "user/fragments/editPins" }} 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 28 </button> 29 - </a> 30 </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"> 47 <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> 51 <div class="flex gap-1 items-center"> 52 {{ i "star" "size-4 fill-current" }} 53 <span>{{ .RepoStats.StarCount }}</span> ··· 55 </div> 56 </label> 57 </div> 58 - {{ end }} 59 - </div> 60 - </form> 61 {{ end }}
··· 1 {{ define "user/fragments/editPins" }} 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 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> 16 </button> 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> 23 </div> 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"> 29 <div class="flex justify-between items-center w-full"> 30 + <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span> 31 <div class="flex gap-1 items-center"> 32 {{ i "star" "size-4 fill-current" }} 33 <span>{{ .RepoStats.StarCount }}</span> ··· 35 </div> 36 </label> 37 </div> 38 + {{ end }} 39 + </div> 40 + 41 + </form> 42 {{ end }}
-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 }}
···
-4
appview/pages/templates/repo/empty.html
··· 44 {{ end }} 45 </main> 46 {{ end }} 47 - 48 - {{ define "repoAfter" }} 49 - {{ template "repo/fragments/cloneInstructions" . }} 50 - {{ end }}
··· 44 {{ end }} 45 </main> 46 {{ end }}
-9
spindle/engine/errors.go
··· 1 - package engine 2 - 3 - import "errors" 4 - 5 - var ( 6 - ErrOOMKilled = errors.New("oom killed") 7 - ErrTimedOut = errors.New("timed out") 8 - ErrWorkflowFailed = errors.New("workflow failed") 9 - )
···
+1 -1
spindle/engine/ansi_stripper.go spindle/engines/nixery/ansi_stripper.go
··· 1 - package engine 2 3 import ( 4 "io"
··· 1 + package nixery 2 3 import ( 4 "io"
+1 -1
spindle/engine/envs.go spindle/engines/nixery/envs.go
··· 1 - package engine 2 3 import ( 4 "fmt"
··· 1 + package nixery 2 3 import ( 4 "fmt"
+1 -1
spindle/engine/envs_test.go spindle/engines/nixery/envs_test.go
··· 1 - package engine 2 3 import ( 4 "testing"
··· 1 + package nixery 2 3 import ( 4 "testing"
+7
spindle/engines/nixery/errors.go
···
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+8 -10
spindle/engine/logger.go spindle/models/logger.go
··· 1 - package engine 2 3 import ( 4 "encoding/json" ··· 7 "os" 8 "path/filepath" 9 "strings" 10 - 11 - "tangled.sh/tangled.sh/core/spindle/models" 12 ) 13 14 type WorkflowLogger struct { ··· 16 encoder *json.Encoder 17 } 18 19 - func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) { 20 path := LogFilePath(baseDir, wid) 21 22 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 30 }, nil 31 } 32 33 - func LogFilePath(baseDir string, workflowID models.WorkflowId) string { 34 logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 35 return logFilePath 36 } ··· 47 } 48 } 49 50 - func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer { 51 return &controlWriter{ 52 logger: l, 53 idx: idx, ··· 62 63 func (w *dataWriter) Write(p []byte) (int, error) { 64 line := strings.TrimRight(string(p), "\r\n") 65 - entry := models.NewDataLogLine(line, w.stream) 66 if err := w.logger.encoder.Encode(entry); err != nil { 67 return 0, err 68 } ··· 72 type controlWriter struct { 73 logger *WorkflowLogger 74 idx int 75 - step models.Step 76 } 77 78 func (w *controlWriter) Write(_ []byte) (int, error) { 79 - entry := models.NewControlLogLine(w.idx, w.step) 80 if err := w.logger.encoder.Encode(entry); err != nil { 81 return 0, err 82 } 83 - return len(w.step.Name), nil 84 }
··· 1 + package models 2 3 import ( 4 "encoding/json" ··· 7 "os" 8 "path/filepath" 9 "strings" 10 ) 11 12 type WorkflowLogger struct { ··· 14 encoder *json.Encoder 15 } 16 17 + func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 path := LogFilePath(baseDir, wid) 19 20 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 28 }, nil 29 } 30 31 + func LogFilePath(baseDir string, workflowID WorkflowId) string { 32 logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 33 return logFilePath 34 } ··· 45 } 46 } 47 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 49 return &controlWriter{ 50 logger: l, 51 idx: idx, ··· 60 61 func (w *dataWriter) Write(p []byte) (int, error) { 62 line := strings.TrimRight(string(p), "\r\n") 63 + entry := NewDataLogLine(line, w.stream) 64 if err := w.logger.encoder.Encode(entry); err != nil { 65 return 0, err 66 } ··· 70 type controlWriter struct { 71 logger *WorkflowLogger 72 idx int 73 + step Step 74 } 75 76 func (w *controlWriter) Write(_ []byte) (int, error) { 77 + entry := NewControlLogLine(w.idx, w.step) 78 if err := w.logger.encoder.Encode(entry); err != nil { 79 return 0, err 80 } 81 + return len(w.step.Name()), nil 82 }
+1 -86
workflow/def_test.go
··· 10 yamlData := ` 11 when: 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 ./...` 25 26 wf, err := FromFile("test.yml", []byte(yamlData)) 27 assert.NoError(t, err, "YAML should unmarshal without error") ··· 30 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 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 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 } 43 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 func TestUnmarshalCloneFalse(t *testing.T) { 72 yamlData := ` 73 when: ··· 75 76 clone: 77 skip: true 78 - 79 - dependencies: 80 - nixpkgs: 81 - - python3 82 - 83 - steps: 84 - - name: Notify 85 - command: | 86 - python3 ./notify.py 87 ` 88 89 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 93 94 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 } 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 - }
··· 10 yamlData := ` 11 when: 12 - event: ["push", "pull_request"] 13 + branch: ["main", "develop"]` 14 15 wf, err := FromFile("test.yml", []byte(yamlData)) 16 assert.NoError(t, err, "YAML should unmarshal without error") ··· 19 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 20 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 21 22 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 23 } 24 25 func TestUnmarshalCloneFalse(t *testing.T) { 26 yamlData := ` 27 when: ··· 29 30 clone: 31 skip: true 32 ` 33 34 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 38 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 40 }
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 {{ define "repo/fragments/repoDescription" }} 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 5 {{ else }} 6 <span class="italic">this repo has no description</span> 7 {{ end }}
··· 1 {{ define "repo/fragments/repoDescription" }} 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 {{ if .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 {{ else }} 6 <span class="italic">this repo has no description</span> 7 {{ end }}
+1 -1
appview/pages/templates/strings/fragments/form.html
··· 13 type="text" 14 id="filename" 15 name="filename" 16 - placeholder="Filename with extension" 17 required 18 value="{{ .String.Filename }}" 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"
··· 13 type="text" 14 id="filename" 15 name="filename" 16 + placeholder="Filename" 17 required 18 value="{{ .String.Filename }}" 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"
-192
appview/pages/templates/settings.html
··· 1 - {{ define "title" }}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="flex flex-col"> 8 - {{ block "profile" . }} {{ end }} 9 - {{ block "keys" . }} {{ end }} 10 - {{ block "emails" . }} {{ end }} 11 - </div> 12 - {{ end }} 13 - 14 - {{ define "profile" }} 15 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2> 16 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 17 - <dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200"> 18 - {{ if .LoggedInUser.Handle }} 19 - <dt class="font-bold">handle</dt> 20 - <dd>@{{ .LoggedInUser.Handle }}</dd> 21 - {{ end }} 22 - <dt class="font-bold">did</dt> 23 - <dd>{{ .LoggedInUser.Did }}</dd> 24 - <dt class="font-bold">pds</dt> 25 - <dd>{{ .LoggedInUser.Pds }}</dd> 26 - </dl> 27 - </section> 28 - {{ end }} 29 - 30 - {{ define "keys" }} 31 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2> 32 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 33 - <p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p> 34 - <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 - {{ range $index, $key := .PubKeys }} 36 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 37 - <div class="flex flex-col gap-1"> 38 - <div class="inline-flex items-center gap-4"> 39 - {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 - <p class="font-bold dark:text-white">{{ .Name }}</p> 41 - </div> 42 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p> 43 - <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 - <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 - </div> 46 - </div> 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 key" 50 - hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}" 51 - hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?" 52 - > 53 - {{ i "trash-2" "w-5 h-5" }} 54 - <span class="hidden md:inline">delete</span> 55 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 56 - </button> 57 - </div> 58 - {{ end }} 59 - </div> 60 - <form 61 - hx-put="/settings/keys" 62 - hx-indicator="#add-sshkey-spinner" 63 - hx-swap="none" 64 - class="max-w-2xl mb-8 space-y-4" 65 - > 66 - <input 67 - type="text" 68 - id="name" 69 - name="name" 70 - placeholder="key name" 71 - required 72 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 73 - 74 - <input 75 - id="key" 76 - name="key" 77 - placeholder="ssh-rsa AAAAAA..." 78 - required 79 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 80 - 81 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit"> 82 - <span>add key</span> 83 - <span id="add-sshkey-spinner" class="group"> 84 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 - </span> 86 - </button> 87 - 88 - <div id="settings-keys" class="error dark:text-red-400"></div> 89 - </form> 90 - </section> 91 - {{ end }} 92 - 93 - {{ define "emails" }} 94 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 95 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 96 - <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 - <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 - {{ range $index, $email := .Emails }} 99 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 100 - <div class="flex flex-col gap-2"> 101 - <div class="inline-flex items-center gap-4"> 102 - {{ i "mail" "w-3 h-3 dark:text-gray-300" }} 103 - <p class="font-bold dark:text-white">{{ .Address }}</p> 104 - <div class="inline-flex items-center gap-1"> 105 - {{ if .Verified }} 106 - <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> 107 - {{ else }} 108 - <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> 109 - {{ end }} 110 - {{ if .Primary }} 111 - <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> 112 - {{ end }} 113 - </div> 114 - </div> 115 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p> 116 - </div> 117 - <div class="flex gap-2 items-center"> 118 - {{ if not .Verified }} 119 - <button 120 - class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 121 - hx-post="/settings/emails/verify/resend" 122 - hx-swap="none" 123 - href="#" 124 - hx-vals='{"email": "{{ .Address }}"}'> 125 - {{ i "rotate-cw" "w-5 h-5" }} 126 - <span class="hidden md:inline">resend</span> 127 - </button> 128 - {{ end }} 129 - {{ if and (not .Primary) .Verified }} 130 - <a 131 - class="text-sm dark:text-blue-400 dark:hover:text-blue-300" 132 - hx-post="/settings/emails/primary" 133 - hx-swap="none" 134 - href="#" 135 - hx-vals='{"email": "{{ .Address }}"}'> 136 - set as primary 137 - </a> 138 - {{ end }} 139 - {{ if not .Primary }} 140 - <form 141 - hx-delete="/settings/emails" 142 - hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?" 143 - hx-indicator="#delete-email-{{ $index }}-spinner" 144 - > 145 - <input type="hidden" name="email" value="{{ .Address }}"> 146 - <button 147 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 148 - title="Delete email" 149 - type="submit" 150 - > 151 - {{ i "trash-2" "w-5 h-5" }} 152 - <span class="hidden md:inline">delete</span> 153 - <span id="delete-email-{{ $index }}-spinner" class="group"> 154 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 155 - </span> 156 - </button> 157 - </form> 158 - {{ end }} 159 - </div> 160 - </div> 161 - {{ end }} 162 - </div> 163 - <form 164 - hx-put="/settings/emails" 165 - hx-swap="none" 166 - class="max-w-2xl mb-8 space-y-4" 167 - hx-indicator="#add-email-spinner" 168 - > 169 - <input 170 - type="email" 171 - id="email" 172 - name="email" 173 - placeholder="your@email.com" 174 - required 175 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 176 - > 177 - 178 - <button 179 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" 180 - type="submit" 181 - > 182 - <span>add email</span> 183 - <span id="add-email-spinner" class="group"> 184 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 185 - </span> 186 - </button> 187 - 188 - <div id="settings-emails-error" class="error dark:text-red-400"></div> 189 - <div id="settings-emails-success" class="success dark:text-green-400"></div> 190 - </form> 191 - </section> 192 - {{ end }}
···
+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
···
··· 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 }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
···
··· 1 + {{ define "user/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+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
···
··· 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
···
··· 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 + }
+45
api/tangled/repohiddenRef.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.hiddenRef 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 + ) 16 + 17 + // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 + type RepoHiddenRef_Input struct { 19 + // forkRef: Fork reference name 20 + ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 + // remoteRef: Remote reference name 22 + RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 + // repo: AT-URI of the repository 24 + Repo string `json:"repo" cborgen:"repo"` 25 + } 26 + 27 + // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 + type RepoHiddenRef_Output struct { 29 + // error: Error message if creation failed 30 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 + // ref: The created hidden ref name 32 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 + // success: Whether the hidden ref was created successfully 34 + Success bool `json:"success" cborgen:"success"` 35 + } 36 + 37 + // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 + func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 + var out RepoHiddenRef_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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 + }
+59
lexicons/repo/hiddenRef.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.hiddenRef", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a hidden ref in a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "forkRef", 15 + "remoteRef" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT-URI of the repository" 22 + }, 23 + "forkRef": { 24 + "type": "string", 25 + "description": "Fork reference name" 26 + }, 27 + "remoteRef": { 28 + "type": "string", 29 + "description": "Remote reference name" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": [ 39 + "success" 40 + ], 41 + "properties": { 42 + "success": { 43 + "type": "boolean", 44 + "description": "Whether the hidden ref was created successfully" 45 + }, 46 + "ref": { 47 + "type": "string", 48 + "description": "The created hidden ref name" 49 + }, 50 + "error": { 51 + "type": "string", 52 + "description": "Error message if creation failed" 53 + } 54 + } 55 + } 56 + } 57 + } 58 + } 59 + }
+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
···
··· 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
···
··· 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
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 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> 18 {{ 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 {{ end }} 22 - </div> 23 </div> 24 - <div id="operation-error" class="dark:text-red-400"></div> 25 </div> 26 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 }} 34 {{ end }} 35 36 - {{ define "knotMember" }} 37 {{ range .Members }} 38 <div> 39 <div class="flex justify-between items-center"> ··· 41 {{ template "user/fragments/picHandleLink" . }} 42 <span class="ml-2 font-mono text-gray-500">{{.}}</span> 43 </div> 44 </div> 45 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 46 {{ $repos := index $.Repos . }} ··· 53 </div> 54 {{ else }} 55 <div class="text-gray-500 dark:text-gray-400"> 56 - No repositories created yet. 57 </div> 58 {{ end }} 59 </div> 60 </div> 61 {{ end }} 62 {{ end }}
··· 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 3 {{ define "content" }} 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 }} 13 {{ template "knots/fragments/addMemberModal" .Registration }} 14 {{ end }} 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 }} 32 </div> 33 </div> 34 + <div id="operation-error" class="dark:text-red-400"></div> 35 + </div> 36 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 }} 44 {{ end }} 45 46 + 47 + {{ define "member" }} 48 {{ range .Members }} 49 <div> 50 <div class="flex justify-between items-center"> ··· 52 {{ template "user/fragments/picHandleLink" . }} 53 <span class="ml-2 font-mono text-gray-500">{{.}}</span> 54 </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 58 </div> 59 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 60 {{ $repos := index $.Repos . }} ··· 67 </div> 68 {{ else }} 69 <div class="text-gray-500 dark:text-gray-400"> 70 + No repositories configured yet. 71 </div> 72 {{ end }} 73 </div> 74 </div> 75 {{ end }} 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
··· 1 {{ define "knots/fragments/addMemberModal" }} 2 <button 3 class="btn gap-2 group" 4 - title="Add member to this spindle" 5 popovertarget="add-member-{{ .Id }}" 6 popovertargetaction="toggle" 7 > ··· 20 21 {{ define "addKnotMemberPopover" }} 22 <form 23 - hx-put="/knots/{{ .Domain }}/member" 24 hx-indicator="#spinner" 25 hx-swap="none" 26 class="flex flex-col gap-2" ··· 28 <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 ADD MEMBER 30 </label> 31 - <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p> 32 <input 33 type="text" 34 id="member-did-{{ .Id }}" 35 - name="subject" 36 required 37 placeholder="@foo.bsky.social" 38 /> 39 <div class="flex gap-2 pt-2"> 40 - <button 41 type="button" 42 popovertarget="add-member-{{ .Id }}" 43 popovertargetaction="hide" ··· 54 </div> 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 </form> 57 - {{ end }} 58 -
··· 1 {{ define "knots/fragments/addMemberModal" }} 2 <button 3 class="btn gap-2 group" 4 + title="Add member to this knot" 5 popovertarget="add-member-{{ .Id }}" 6 popovertargetaction="toggle" 7 > ··· 20 21 {{ define "addKnotMemberPopover" }} 22 <form 23 + hx-post="/knots/{{ .Domain }}/add" 24 hx-indicator="#spinner" 25 hx-swap="none" 26 class="flex flex-col gap-2" ··· 28 <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 ADD MEMBER 30 </label> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 <input 33 type="text" 34 id="member-did-{{ .Id }}" 35 + name="member" 36 required 37 placeholder="@foo.bsky.social" 38 /> 39 <div class="flex gap-2 pt-2"> 40 + <button 41 type="button" 42 popovertarget="add-member-{{ .Id }}" 43 popovertargetaction="hide" ··· 54 </div> 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 </form> 57 + {{ end }}
-7
nix/modules/knot.nix
··· 99 description = "DID of owner (required)"; 100 }; 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 dbPath = mkOption { 109 type = types.path; 110 default = "${cfg.stateDir}/knotserver.db"; ··· 207 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 208 "KNOT_SERVER_OWNER=${cfg.server.owner}" 209 ]; 210 - EnvironmentFile = cfg.server.secretFile; 211 ExecStart = "${cfg.package}/bin/knot server"; 212 Restart = "always"; 213 };
··· 99 description = "DID of owner (required)"; 100 }; 101 102 dbPath = mkOption { 103 type = types.path; 104 default = "${cfg.stateDir}/knotserver.db"; ··· 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 "KNOT_SERVER_OWNER=${cfg.server.owner}" 203 ]; 204 ExecStart = "${cfg.package}/bin/knot server"; 205 Restart = "always"; 206 };
+2 -2
rbac/rbac.go
··· 281 return e.E.Enforce(user, domain, domain, "repo:create") 282 } 283 284 - func (e *Enforcer) IsRepoDeleteAllowed(user, domain string) (bool, error) { 285 - return e.E.Enforce(user, domain, domain, "repo:delete") 286 } 287 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
··· 281 return e.E.Enforce(user, domain, domain, "repo:create") 282 } 283 284 + func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { 285 + return e.E.Enforce(user, domain, repo, "repo:delete") 286 } 287 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+4 -7
api/tangled/pullcomment.go
··· 17 } // 18 // RECORDTYPE: RepoPullComment 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"` 27 }
··· 17 } // 18 // RECORDTYPE: RepoPullComment 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 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Pull string `json:"pull" cborgen:"pull"` 24 }
-11
lexicons/pulls/comment.json
··· 19 "type": "string", 20 "format": "at-uri" 21 }, 22 - "repo": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 - }, 29 - "owner": { 30 - "type": "string", 31 - "format": "did" 32 - }, 33 "body": { 34 "type": "string" 35 },
··· 19 "type": "string", 20 "format": "at-uri" 21 }, 22 "body": { 23 "type": "string" 24 },
+7 -2
api/tangled/repopull.go
··· 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Patch string `json:"patch" cborgen:"patch"` 24 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 25 - TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` 26 - TargetRepo string `json:"targetRepo" cborgen:"targetRepo"` 27 Title string `json:"title" cborgen:"title"` 28 } 29 ··· 33 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 34 Sha string `json:"sha" cborgen:"sha"` 35 }
··· 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Patch string `json:"patch" cborgen:"patch"` 24 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 25 + Target *RepoPull_Target `json:"target" cborgen:"target"` 26 Title string `json:"title" cborgen:"title"` 27 } 28 ··· 32 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 33 Sha string `json:"sha" cborgen:"sha"` 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
··· 10 "record": { 11 "type": "object", 12 "required": [ 13 - "targetRepo", 14 - "targetBranch", 15 "title", 16 "patch", 17 "createdAt" 18 ], 19 "properties": { 20 - "targetRepo": { 21 - "type": "string", 22 - "format": "at-uri" 23 - }, 24 - "targetBranch": { 25 - "type": "string" 26 }, 27 "title": { 28 "type": "string" ··· 44 } 45 } 46 }, 47 "source": { 48 "type": "object", 49 "required": [
··· 10 "record": { 11 "type": "object", 12 "required": [ 13 + "target", 14 "title", 15 "patch", 16 "createdAt" 17 ], 18 "properties": { 19 + "target": { 20 + "type": "ref", 21 + "ref": "#target" 22 }, 23 "title": { 24 "type": "string" ··· 40 } 41 } 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 + }, 59 "source": { 60 "type": "object", 61 "required": [
+2 -2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 19 > 20 <option disabled selected>select a fork</option> 21 {{ range .Forks }} 22 - <option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 - {{ .Name }} 24 </option> 25 {{ end }} 26 </select>
··· 19 > 20 <option disabled selected>select a fork</option> 21 {{ range .Forks }} 22 + <option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 + {{ .Did | resolve }}/{{ .Name }} 24 </option> 25 {{ end }} 26 </select>
+3 -3
docs/contributing.md
··· 11 ### message format 12 13 ``` 14 - <service/top-level directory>: <affected package/directory>: <short summary of change> 15 16 17 Optional longer description can go here, if necessary. Explain what the ··· 23 Here are some examples: 24 25 ``` 26 - appview: state: fix token expiry check in middleware 27 28 The previous check did not account for clock drift, leading to premature 29 token invalidation. 30 ``` 31 32 ``` 33 - knotserver: git/service: improve error checking in upload-pack 34 ``` 35 36
··· 11 ### message format 12 13 ``` 14 + <service/top-level directory>/<affected package/directory>: <short summary of change> 15 16 17 Optional longer description can go here, if necessary. Explain what the ··· 23 Here are some examples: 24 25 ``` 26 + appview/state: fix token expiry check in middleware 27 28 The previous check did not account for clock drift, leading to premature 29 token invalidation. 30 ``` 31 32 ``` 33 + knotserver/git/service: improve error checking in upload-pack 34 ``` 35 36
-2
api/tangled/repoissue.go
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 - Owner string `json:"owner" cborgen:"owner"` 25 Repo string `json:"repo" cborgen:"repo"` 26 Title string `json:"title" cborgen:"title"` 27 }
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Repo string `json:"repo" cborgen:"repo"` 24 Title string `json:"title" cborgen:"title"` 25 }
+1 -14
lexicons/issue/issue.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 19 "properties": { 20 "repo": { 21 "type": "string", 22 "format": "at-uri" 23 }, 24 - "issueId": { 25 - "type": "integer" 26 - }, 27 - "owner": { 28 - "type": "string", 29 - "format": "did" 30 - }, 31 "title": { 32 "type": "string" 33 },
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": ["repo", "title", "createdAt"], 13 "properties": { 14 "repo": { 15 "type": "string", 16 "format": "at-uri" 17 }, 18 "title": { 19 "type": "string" 20 },
+16
nix/modules/spindle.nix
··· 55 description = "DID of owner (required)"; 56 }; 57 58 secrets = { 59 provider = mkOption { 60 type = types.str; ··· 108 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 109 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 110 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 111 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
··· 55 description = "DID of owner (required)"; 56 }; 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 + 72 secrets = { 73 provider = mkOption { 74 type = types.str; ··· 122 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 123 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 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}" 127 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 128 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 129 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
+2
nix/vm.nix
··· 89 hostname = "localhost:6555"; 90 listenAddr = "0.0.0.0:6555"; 91 dev = true; 92 secrets = { 93 provider = "sqlite"; 94 };
··· 89 hostname = "localhost:6555"; 90 listenAddr = "0.0.0.0:6555"; 91 dev = true; 92 + queueSize = 100; 93 + maxJobCount = 2; 94 secrets = { 95 provider = "sqlite"; 96 };
+2
spindle/config/config.go
··· 17 Owner string `env:"OWNER, required"` 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 20 } 21 22 func (s Server) Did() syntax.DID {
··· 17 Owner string `env:"OWNER, required"` 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 20 + QueueSize int `env:"QUEUE_SIZE, default=100"` 21 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 22 } 23 24 func (s Server) Did() syntax.DID {
+51 -50
docs/hacking.md
··· 48 redis-server 49 ``` 50 51 - ## running knots and spindles in a VM 52 53 An end-to-end knot setup requires setting up a machine with 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 58 - ### Mac-specific: setting up a Nix builder 59 - 60 - In order to build Tangled's dev VM on macOS, you will first need to set up a 61 - Linux Nix builder. The recommended way to do so is to run a 62 - [`darwin.linux-builder` VM][darwin builder vm] and to register it in `nix.conf` 63 - as a builder for Linux with the same architecture as your Mac (`linux-aarch64` 64 - if you are using Apple Silicon). 65 - 66 - > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 67 - > the tangled repo so that it doesn't conflict with the other VM. For example, 68 - > you can do 69 - > 70 - > ```shell 71 - > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 72 - > ``` 73 - > 74 - > to store the builder VM in a temporary dir. 75 - > 76 - > You should read and follow [all the other intructions][darwin builder vm] to 77 - > avoid subtle problems. 78 - 79 - Alternatively, you can use any other method to set up a Linux machine with `nix` 80 - installed that you can `sudo ssh` into (in other words, root user on your Mac 81 - has to be able to ssh into the Linux machine without entering a password) and 82 - that has the same architecture as your Mac. See [remote builder instructions] 83 - for how to register such a builder in `nix.conf`. 84 - 85 - > WARNING: If you'd like to use 86 - > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 87 - > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 88 - > ssh` works can be tricky. It seems to be [possible with 89 - > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 90 - 91 - [darwin builder vm]: 92 - https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder 93 - [remote builder instructions]: 94 - https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements 95 - 96 - ### Running a knot on a dev VM 97 98 To begin, grab your DID from http://localhost:3000/settings. 99 Then, set `TANGLED_VM_KNOT_OWNER` and 100 - `TANGLED_VM_SPINDLE_OWNER` to your DID. 101 - 102 - If you don't want to [set up a spindle](#running-a-spindle), 103 - you can use any placeholder value. 104 - 105 - You can now start a lightweight NixOS VM like so: 106 107 ```bash 108 nix run --impure .#vm ··· 114 with `ssh` exposed on port 2222. 115 116 Once the services are running, head to 117 - http://localhost:3000/knots and hit verify (and similarly, 118 - http://localhost:3000/spindles to verify your spindle). It 119 - should verify the ownership of the services instantly if 120 - everything went smoothly. 121 122 You can push repositories to this VM with this ssh config 123 block on your main machine: ··· 159 # litecli has a nicer REPL interface: 160 litecli /var/lib/spindle/spindle.db 161 ```
··· 48 redis-server 49 ``` 50 51 + ## running knots and spindles 52 53 An end-to-end knot setup requires setting up a machine with 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 58 + <details> 59 + <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 60 + 61 + In order to build Tangled's dev VM on macOS, you will 62 + first need to set up a Linux Nix builder. The recommended 63 + way to do so is to run a [`darwin.linux-builder` 64 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 65 + and to register it in `nix.conf` as a builder for Linux 66 + with the same architecture as your Mac (`linux-aarch64` if 67 + you are using Apple Silicon). 68 + 69 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 70 + > the tangled repo so that it doesn't conflict with the other VM. For example, 71 + > you can do 72 + > 73 + > ```shell 74 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 75 + > ``` 76 + > 77 + > to store the builder VM in a temporary dir. 78 + > 79 + > You should read and follow [all the other intructions][darwin builder vm] to 80 + > avoid subtle problems. 81 + 82 + Alternatively, you can use any other method to set up a 83 + Linux machine with `nix` installed that you can `sudo ssh` 84 + into (in other words, root user on your Mac has to be able 85 + to ssh into the Linux machine without entering a password) 86 + and that has the same architecture as your Mac. See 87 + [remote builder 88 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 89 + for how to register such a builder in `nix.conf`. 90 + 91 + > WARNING: If you'd like to use 92 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 93 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 94 + > ssh` works can be tricky. It seems to be [possible with 95 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 96 + 97 + </details> 98 99 To begin, grab your DID from http://localhost:3000/settings. 100 Then, set `TANGLED_VM_KNOT_OWNER` and 101 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 102 + lightweight NixOS VM like so: 103 104 ```bash 105 nix run --impure .#vm ··· 111 with `ssh` exposed on port 2222. 112 113 Once the services are running, head to 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 117 118 You can push repositories to this VM with this ssh config 119 block on your main machine: ··· 155 # litecli has a nicer REPL interface: 156 litecli /var/lib/spindle/spindle.db 157 ``` 158 + 159 + If for any reason you wish to disable either one of the 160 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 + `services.tangled-spindle.enable` (or 162 + `services.tangled-knot.enable`) to `false`.
+4
appview/pages/templates/repo/fragments/duration.html
···
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+4
appview/pages/templates/repo/fragments/shortTime.html
···
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
-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 {{ define "repo/fragments/time" }} 6 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 {{ 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 }}
··· 1 {{ define "repo/fragments/time" }} 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 3 {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
···
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 52 </div> 53 {{ end }} 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) }} 56 </div> 57 </div> 58 </a>
··· 52 </div> 53 {{ end }} 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 </div> 57 </div> 58 </a>
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 1 - {{ define "repo/pulls/fragments/summarizedHeader" }} 2 {{ $pull := index . 0 }} 3 {{ $pipeline := index . 1 }} 4 {{ with $pull }}
··· 1 + {{ define "repo/pulls/fragments/summarizedPullHeader" }} 2 {{ $pull := index . 0 }} 3 {{ $pipeline := index . 1 }} 4 {{ with $pull }}
+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
··· 13 <label class="m-0 p-0" for="description">bio</label> 14 <textarea 15 type="text" 16 - class="py-1 px-1 w-full" 17 name="description" 18 rows="3" 19 placeholder="write a bio">{{ $description }}</textarea>
··· 13 <label class="m-0 p-0" for="description">bio</label> 14 <textarea 15 type="text" 16 + class="p-2 w-full" 17 name="description" 18 rows="3" 19 placeholder="write a bio">{{ $description }}</textarea>
+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
··· 92 {{ with $root }} 93 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 96 <span class="select-none after:content-['·']"></span> 97 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 98 </div> 99 {{ end }} 100 {{ end }}
··· 92 {{ with $root }} 93 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 96 <span class="select-none after:content-['·']"></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 98 </div> 99 {{ end }} 100 {{ end }}
+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 }}
+1 -1
patchutil/combinediff.go
··· 119 // we have f1 and f2, combine them 120 combined, err := combineFiles(f1, f2) 121 if err != nil { 122 - fmt.Println(err) 123 } 124 125 // combined can be nil commit 2 reverted all changes from commit 1
··· 119 // we have f1 and f2, combine them 120 combined, err := combineFiles(f1, f2) 121 if err != nil { 122 + // fmt.Println(err) 123 } 124 125 // combined can be nil commit 2 reverted all changes from commit 1
+1 -1
appview/pages/templates/errors/knot404.html
··· 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 </p> 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"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 back to timeline 23 </a>
··· 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 back to timeline 23 </a>
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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 + }
+39
api/tangled/repotags.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tags 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagsNSID = "sh.tangled.repo.tags" 16 + ) 17 + 18 + // RepoTags calls the XRPC method "sh.tangled.repo.tags". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of tags to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoTags(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.tags", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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 + }
+43
lexicons/repo/tags.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tags", 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 tags 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 + }
-1069
knotserver/handler.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "compress/gzip" 5 - "context" 6 - "crypto/sha256" 7 - "encoding/json" 8 - "errors" 9 - "fmt" 10 - "log" 11 - "net/http" 12 - "net/url" 13 - "path/filepath" 14 - "strconv" 15 - "strings" 16 - "sync" 17 - "time" 18 - 19 - securejoin "github.com/cyphar/filepath-securejoin" 20 - "github.com/gliderlabs/ssh" 21 - "github.com/go-chi/chi/v5" 22 - "github.com/go-git/go-git/v5/plumbing" 23 - "github.com/go-git/go-git/v5/plumbing/object" 24 - "tangled.sh/tangled.sh/core/knotserver/db" 25 - "tangled.sh/tangled.sh/core/knotserver/git" 26 - "tangled.sh/tangled.sh/core/types" 27 - ) 28 - 29 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 30 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 31 - } 32 - 33 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 34 - w.Header().Set("Content-Type", "application/json") 35 - 36 - capabilities := map[string]any{ 37 - "pull_requests": map[string]any{ 38 - "format_patch": true, 39 - "patch_submissions": true, 40 - "branch_submissions": true, 41 - "fork_submissions": true, 42 - }, 43 - "xrpc": true, 44 - } 45 - 46 - jsonData, err := json.Marshal(capabilities) 47 - if err != nil { 48 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 49 - return 50 - } 51 - 52 - w.Write(jsonData) 53 - } 54 - 55 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 56 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 57 - l := h.l.With("path", path, "handler", "RepoIndex") 58 - ref := chi.URLParam(r, "ref") 59 - ref, _ = url.PathUnescape(ref) 60 - 61 - gr, err := git.Open(path, ref) 62 - if err != nil { 63 - plain, err2 := git.PlainOpen(path) 64 - if err2 != nil { 65 - l.Error("opening repo", "error", err2.Error()) 66 - notFound(w) 67 - return 68 - } 69 - branches, _ := plain.Branches() 70 - 71 - log.Println(err) 72 - 73 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 74 - resp := types.RepoIndexResponse{ 75 - IsEmpty: true, 76 - Branches: branches, 77 - } 78 - writeJSON(w, resp) 79 - return 80 - } else { 81 - l.Error("opening repo", "error", err.Error()) 82 - notFound(w) 83 - return 84 - } 85 - } 86 - 87 - var ( 88 - commits []*object.Commit 89 - total int 90 - branches []types.Branch 91 - files []types.NiceTree 92 - tags []object.Tag 93 - ) 94 - 95 - var wg sync.WaitGroup 96 - errorsCh := make(chan error, 5) 97 - 98 - wg.Add(1) 99 - go func() { 100 - defer wg.Done() 101 - cs, err := gr.Commits(0, 60) 102 - if err != nil { 103 - errorsCh <- fmt.Errorf("commits: %w", err) 104 - return 105 - } 106 - commits = cs 107 - }() 108 - 109 - wg.Add(1) 110 - go func() { 111 - defer wg.Done() 112 - t, err := gr.TotalCommits() 113 - if err != nil { 114 - errorsCh <- fmt.Errorf("calculating total: %w", err) 115 - return 116 - } 117 - total = t 118 - }() 119 - 120 - wg.Add(1) 121 - go func() { 122 - defer wg.Done() 123 - bs, err := gr.Branches() 124 - if err != nil { 125 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 126 - return 127 - } 128 - branches = bs 129 - }() 130 - 131 - wg.Add(1) 132 - go func() { 133 - defer wg.Done() 134 - ts, err := gr.Tags() 135 - if err != nil { 136 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 137 - return 138 - } 139 - tags = ts 140 - }() 141 - 142 - wg.Add(1) 143 - go func() { 144 - defer wg.Done() 145 - fs, err := gr.FileTree(r.Context(), "") 146 - if err != nil { 147 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 148 - return 149 - } 150 - files = fs 151 - }() 152 - 153 - wg.Wait() 154 - close(errorsCh) 155 - 156 - // show any errors 157 - for err := range errorsCh { 158 - l.Error("loading repo", "error", err.Error()) 159 - writeError(w, err.Error(), http.StatusInternalServerError) 160 - return 161 - } 162 - 163 - rtags := []*types.TagReference{} 164 - for _, tag := range tags { 165 - var target *object.Tag 166 - if tag.Target != plumbing.ZeroHash { 167 - target = &tag 168 - } 169 - tr := types.TagReference{ 170 - Tag: target, 171 - } 172 - 173 - tr.Reference = types.Reference{ 174 - Name: tag.Name, 175 - Hash: tag.Hash.String(), 176 - } 177 - 178 - if tag.Message != "" { 179 - tr.Message = tag.Message 180 - } 181 - 182 - rtags = append(rtags, &tr) 183 - } 184 - 185 - var readmeContent string 186 - var readmeFile string 187 - for _, readme := range h.c.Repo.Readme { 188 - content, _ := gr.FileContent(readme) 189 - if len(content) > 0 { 190 - readmeContent = string(content) 191 - readmeFile = readme 192 - } 193 - } 194 - 195 - if ref == "" { 196 - mainBranch, err := gr.FindMainBranch() 197 - if err != nil { 198 - writeError(w, err.Error(), http.StatusInternalServerError) 199 - l.Error("finding main branch", "error", err.Error()) 200 - return 201 - } 202 - ref = mainBranch 203 - } 204 - 205 - resp := types.RepoIndexResponse{ 206 - IsEmpty: false, 207 - Ref: ref, 208 - Commits: commits, 209 - Description: getDescription(path), 210 - Readme: readmeContent, 211 - ReadmeFileName: readmeFile, 212 - Files: files, 213 - Branches: branches, 214 - Tags: rtags, 215 - TotalCommits: total, 216 - } 217 - 218 - writeJSON(w, resp) 219 - } 220 - 221 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 222 - treePath := chi.URLParam(r, "*") 223 - ref := chi.URLParam(r, "ref") 224 - ref, _ = url.PathUnescape(ref) 225 - 226 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 227 - 228 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 229 - gr, err := git.Open(path, ref) 230 - if err != nil { 231 - notFound(w) 232 - return 233 - } 234 - 235 - files, err := gr.FileTree(r.Context(), treePath) 236 - if err != nil { 237 - writeError(w, err.Error(), http.StatusInternalServerError) 238 - l.Error("file tree", "error", err.Error()) 239 - return 240 - } 241 - 242 - resp := types.RepoTreeResponse{ 243 - Ref: ref, 244 - Parent: treePath, 245 - Description: getDescription(path), 246 - DotDot: filepath.Dir(treePath), 247 - Files: files, 248 - } 249 - 250 - writeJSON(w, resp) 251 - } 252 - 253 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 254 - treePath := chi.URLParam(r, "*") 255 - ref := chi.URLParam(r, "ref") 256 - ref, _ = url.PathUnescape(ref) 257 - 258 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 259 - 260 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 261 - gr, err := git.Open(path, ref) 262 - if err != nil { 263 - notFound(w) 264 - return 265 - } 266 - 267 - contents, err := gr.RawContent(treePath) 268 - if err != nil { 269 - writeError(w, err.Error(), http.StatusBadRequest) 270 - l.Error("file content", "error", err.Error()) 271 - return 272 - } 273 - 274 - mimeType := http.DetectContentType(contents) 275 - 276 - // exception for svg 277 - if filepath.Ext(treePath) == ".svg" { 278 - mimeType = "image/svg+xml" 279 - } 280 - 281 - contentHash := sha256.Sum256(contents) 282 - eTag := fmt.Sprintf("\"%x\"", contentHash) 283 - 284 - // allow image, video, and text/plain files to be served directly 285 - switch { 286 - case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 287 - if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 288 - w.WriteHeader(http.StatusNotModified) 289 - return 290 - } 291 - w.Header().Set("ETag", eTag) 292 - 293 - case strings.HasPrefix(mimeType, "text/plain"): 294 - w.Header().Set("Cache-Control", "public, no-cache") 295 - 296 - default: 297 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 298 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 299 - return 300 - } 301 - 302 - w.Header().Set("Content-Type", mimeType) 303 - w.Write(contents) 304 - } 305 - 306 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 307 - treePath := chi.URLParam(r, "*") 308 - ref := chi.URLParam(r, "ref") 309 - ref, _ = url.PathUnescape(ref) 310 - 311 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 312 - 313 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 314 - gr, err := git.Open(path, ref) 315 - if err != nil { 316 - notFound(w) 317 - return 318 - } 319 - 320 - var isBinaryFile bool = false 321 - contents, err := gr.FileContent(treePath) 322 - if errors.Is(err, git.ErrBinaryFile) { 323 - isBinaryFile = true 324 - } else if errors.Is(err, object.ErrFileNotFound) { 325 - notFound(w) 326 - return 327 - } else if err != nil { 328 - writeError(w, err.Error(), http.StatusInternalServerError) 329 - return 330 - } 331 - 332 - bytes := []byte(contents) 333 - // safe := string(sanitize(bytes)) 334 - sizeHint := len(bytes) 335 - 336 - resp := types.RepoBlobResponse{ 337 - Ref: ref, 338 - Contents: string(bytes), 339 - Path: treePath, 340 - IsBinary: isBinaryFile, 341 - SizeHint: uint64(sizeHint), 342 - } 343 - 344 - h.showFile(resp, w, l) 345 - } 346 - 347 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 348 - name := chi.URLParam(r, "name") 349 - file := chi.URLParam(r, "file") 350 - 351 - l := h.l.With("handler", "Archive", "name", name, "file", file) 352 - 353 - // TODO: extend this to add more files compression (e.g.: xz) 354 - if !strings.HasSuffix(file, ".tar.gz") { 355 - notFound(w) 356 - return 357 - } 358 - 359 - ref := strings.TrimSuffix(file, ".tar.gz") 360 - 361 - unescapedRef, err := url.PathUnescape(ref) 362 - if err != nil { 363 - notFound(w) 364 - return 365 - } 366 - 367 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 368 - 369 - // This allows the browser to use a proper name for the file when 370 - // downloading 371 - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 372 - setContentDisposition(w, filename) 373 - setGZipMIME(w) 374 - 375 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 376 - gr, err := git.Open(path, unescapedRef) 377 - if err != nil { 378 - notFound(w) 379 - return 380 - } 381 - 382 - gw := gzip.NewWriter(w) 383 - defer gw.Close() 384 - 385 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 386 - err = gr.WriteTar(gw, prefix) 387 - if err != nil { 388 - // once we start writing to the body we can't report error anymore 389 - // so we are only left with printing the error. 390 - l.Error("writing tar file", "error", err.Error()) 391 - return 392 - } 393 - 394 - err = gw.Flush() 395 - if err != nil { 396 - // once we start writing to the body we can't report error anymore 397 - // so we are only left with printing the error. 398 - l.Error("flushing?", "error", err.Error()) 399 - return 400 - } 401 - } 402 - 403 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 404 - ref := chi.URLParam(r, "ref") 405 - ref, _ = url.PathUnescape(ref) 406 - 407 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 408 - 409 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 410 - 411 - gr, err := git.Open(path, ref) 412 - if err != nil { 413 - notFound(w) 414 - return 415 - } 416 - 417 - // Get page parameters 418 - page := 1 419 - pageSize := 30 420 - 421 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 422 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 423 - page = p 424 - } 425 - } 426 - 427 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 428 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 429 - pageSize = ps 430 - } 431 - } 432 - 433 - // convert to offset/limit 434 - offset := (page - 1) * pageSize 435 - limit := pageSize 436 - 437 - commits, err := gr.Commits(offset, limit) 438 - if err != nil { 439 - writeError(w, err.Error(), http.StatusInternalServerError) 440 - l.Error("fetching commits", "error", err.Error()) 441 - return 442 - } 443 - 444 - total := len(commits) 445 - 446 - resp := types.RepoLogResponse{ 447 - Commits: commits, 448 - Ref: ref, 449 - Description: getDescription(path), 450 - Log: true, 451 - Total: total, 452 - Page: page, 453 - PerPage: pageSize, 454 - } 455 - 456 - writeJSON(w, resp) 457 - } 458 - 459 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 460 - ref := chi.URLParam(r, "ref") 461 - ref, _ = url.PathUnescape(ref) 462 - 463 - l := h.l.With("handler", "Diff", "ref", ref) 464 - 465 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 466 - gr, err := git.Open(path, ref) 467 - if err != nil { 468 - notFound(w) 469 - return 470 - } 471 - 472 - diff, err := gr.Diff() 473 - if err != nil { 474 - writeError(w, err.Error(), http.StatusInternalServerError) 475 - l.Error("getting diff", "error", err.Error()) 476 - return 477 - } 478 - 479 - resp := types.RepoCommitResponse{ 480 - Ref: ref, 481 - Diff: diff, 482 - } 483 - 484 - writeJSON(w, resp) 485 - } 486 - 487 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 488 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 489 - l := h.l.With("handler", "Refs") 490 - 491 - gr, err := git.Open(path, "") 492 - if err != nil { 493 - notFound(w) 494 - return 495 - } 496 - 497 - tags, err := gr.Tags() 498 - if err != nil { 499 - // Non-fatal, we *should* have at least one branch to show. 500 - l.Warn("getting tags", "error", err.Error()) 501 - } 502 - 503 - rtags := []*types.TagReference{} 504 - for _, tag := range tags { 505 - var target *object.Tag 506 - if tag.Target != plumbing.ZeroHash { 507 - target = &tag 508 - } 509 - tr := types.TagReference{ 510 - Tag: target, 511 - } 512 - 513 - tr.Reference = types.Reference{ 514 - Name: tag.Name, 515 - Hash: tag.Hash.String(), 516 - } 517 - 518 - if tag.Message != "" { 519 - tr.Message = tag.Message 520 - } 521 - 522 - rtags = append(rtags, &tr) 523 - } 524 - 525 - resp := types.RepoTagsResponse{ 526 - Tags: rtags, 527 - } 528 - 529 - writeJSON(w, resp) 530 - } 531 - 532 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 533 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 534 - 535 - gr, err := git.PlainOpen(path) 536 - if err != nil { 537 - notFound(w) 538 - return 539 - } 540 - 541 - branches, _ := gr.Branches() 542 - 543 - resp := types.RepoBranchesResponse{ 544 - Branches: branches, 545 - } 546 - 547 - writeJSON(w, resp) 548 - } 549 - 550 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 551 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 552 - branchName := chi.URLParam(r, "branch") 553 - branchName, _ = url.PathUnescape(branchName) 554 - 555 - l := h.l.With("handler", "Branch") 556 - 557 - gr, err := git.PlainOpen(path) 558 - if err != nil { 559 - notFound(w) 560 - return 561 - } 562 - 563 - ref, err := gr.Branch(branchName) 564 - if err != nil { 565 - l.Error("getting branch", "error", err.Error()) 566 - writeError(w, err.Error(), http.StatusInternalServerError) 567 - return 568 - } 569 - 570 - commit, err := gr.Commit(ref.Hash()) 571 - if err != nil { 572 - l.Error("getting commit object", "error", err.Error()) 573 - writeError(w, err.Error(), http.StatusInternalServerError) 574 - return 575 - } 576 - 577 - defaultBranch, err := gr.FindMainBranch() 578 - isDefault := false 579 - if err != nil { 580 - l.Error("getting default branch", "error", err.Error()) 581 - // do not quit though 582 - } else if defaultBranch == branchName { 583 - isDefault = true 584 - } 585 - 586 - resp := types.RepoBranchResponse{ 587 - Branch: types.Branch{ 588 - Reference: types.Reference{ 589 - Name: ref.Name().Short(), 590 - Hash: ref.Hash().String(), 591 - }, 592 - Commit: commit, 593 - IsDefault: isDefault, 594 - }, 595 - } 596 - 597 - writeJSON(w, resp) 598 - } 599 - 600 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 - l := h.l.With("handler", "Keys") 602 - 603 - switch r.Method { 604 - case http.MethodGet: 605 - keys, err := h.db.GetAllPublicKeys() 606 - if err != nil { 607 - writeError(w, err.Error(), http.StatusInternalServerError) 608 - l.Error("getting public keys", "error", err.Error()) 609 - return 610 - } 611 - 612 - data := make([]map[string]any, 0) 613 - for _, key := range keys { 614 - j := key.JSON() 615 - data = append(data, j) 616 - } 617 - writeJSON(w, data) 618 - return 619 - 620 - case http.MethodPut: 621 - pk := db.PublicKey{} 622 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 - writeError(w, "invalid request body", http.StatusBadRequest) 624 - return 625 - } 626 - 627 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 - if err != nil { 629 - writeError(w, "invalid pubkey", http.StatusBadRequest) 630 - } 631 - 632 - if err := h.db.AddPublicKey(pk); err != nil { 633 - writeError(w, err.Error(), http.StatusInternalServerError) 634 - l.Error("adding public key", "error", err.Error()) 635 - return 636 - } 637 - 638 - w.WriteHeader(http.StatusNoContent) 639 - return 640 - } 641 - } 642 - 643 - // func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 644 - // l := h.l.With("handler", "RepoForkSync") 645 - // 646 - // data := struct { 647 - // Did string `json:"did"` 648 - // Source string `json:"source"` 649 - // Name string `json:"name,omitempty"` 650 - // HiddenRef string `json:"hiddenref"` 651 - // }{} 652 - // 653 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 654 - // writeError(w, "invalid request body", http.StatusBadRequest) 655 - // return 656 - // } 657 - // 658 - // did := data.Did 659 - // source := data.Source 660 - // 661 - // if did == "" || source == "" { 662 - // l.Error("invalid request body, empty did or name") 663 - // w.WriteHeader(http.StatusBadRequest) 664 - // return 665 - // } 666 - // 667 - // var name string 668 - // if data.Name != "" { 669 - // name = data.Name 670 - // } else { 671 - // name = filepath.Base(source) 672 - // } 673 - // 674 - // branch := chi.URLParam(r, "branch") 675 - // branch, _ = url.PathUnescape(branch) 676 - // 677 - // relativeRepoPath := filepath.Join(did, name) 678 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 679 - // 680 - // gr, err := git.PlainOpen(repoPath) 681 - // if err != nil { 682 - // log.Println(err) 683 - // notFound(w) 684 - // return 685 - // } 686 - // 687 - // forkCommit, err := gr.ResolveRevision(branch) 688 - // if err != nil { 689 - // l.Error("error resolving ref revision", "msg", err.Error()) 690 - // writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 691 - // return 692 - // } 693 - // 694 - // sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 695 - // if err != nil { 696 - // l.Error("error resolving hidden ref revision", "msg", err.Error()) 697 - // writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 698 - // return 699 - // } 700 - // 701 - // status := types.UpToDate 702 - // if forkCommit.Hash.String() != sourceCommit.Hash.String() { 703 - // isAncestor, err := forkCommit.IsAncestor(sourceCommit) 704 - // if err != nil { 705 - // log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 706 - // return 707 - // } 708 - // 709 - // if isAncestor { 710 - // status = types.FastForwardable 711 - // } else { 712 - // status = types.Conflict 713 - // } 714 - // } 715 - // 716 - // w.Header().Set("Content-Type", "application/json") 717 - // json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 718 - // } 719 - 720 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 721 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 722 - ref := chi.URLParam(r, "ref") 723 - ref, _ = url.PathUnescape(ref) 724 - 725 - l := h.l.With("handler", "RepoLanguages") 726 - 727 - gr, err := git.Open(repoPath, ref) 728 - if err != nil { 729 - l.Error("opening repo", "error", err.Error()) 730 - notFound(w) 731 - return 732 - } 733 - 734 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 735 - defer cancel() 736 - 737 - sizes, err := gr.AnalyzeLanguages(ctx) 738 - if err != nil { 739 - l.Error("failed to analyze languages", "error", err.Error()) 740 - writeError(w, err.Error(), http.StatusNoContent) 741 - return 742 - } 743 - 744 - resp := types.RepoLanguageResponse{Languages: sizes} 745 - 746 - writeJSON(w, resp) 747 - } 748 - 749 - // func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 750 - // l := h.l.With("handler", "RepoForkSync") 751 - // 752 - // data := struct { 753 - // Did string `json:"did"` 754 - // Source string `json:"source"` 755 - // Name string `json:"name,omitempty"` 756 - // }{} 757 - // 758 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 759 - // writeError(w, "invalid request body", http.StatusBadRequest) 760 - // return 761 - // } 762 - // 763 - // did := data.Did 764 - // source := data.Source 765 - // 766 - // if did == "" || source == "" { 767 - // l.Error("invalid request body, empty did or name") 768 - // w.WriteHeader(http.StatusBadRequest) 769 - // return 770 - // } 771 - // 772 - // var name string 773 - // if data.Name != "" { 774 - // name = data.Name 775 - // } else { 776 - // name = filepath.Base(source) 777 - // } 778 - // 779 - // branch := chi.URLParam(r, "branch") 780 - // branch, _ = url.PathUnescape(branch) 781 - // 782 - // relativeRepoPath := filepath.Join(did, name) 783 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 784 - // 785 - // gr, err := git.Open(repoPath, branch) 786 - // if err != nil { 787 - // log.Println(err) 788 - // notFound(w) 789 - // return 790 - // } 791 - // 792 - // err = gr.Sync() 793 - // if err != nil { 794 - // l.Error("error syncing repo fork", "error", err.Error()) 795 - // writeError(w, err.Error(), http.StatusInternalServerError) 796 - // return 797 - // } 798 - // 799 - // w.WriteHeader(http.StatusNoContent) 800 - // } 801 - 802 - // func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 803 - // l := h.l.With("handler", "RepoFork") 804 - // 805 - // data := struct { 806 - // Did string `json:"did"` 807 - // Source string `json:"source"` 808 - // Name string `json:"name,omitempty"` 809 - // }{} 810 - // 811 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 812 - // writeError(w, "invalid request body", http.StatusBadRequest) 813 - // return 814 - // } 815 - // 816 - // did := data.Did 817 - // source := data.Source 818 - // 819 - // if did == "" || source == "" { 820 - // l.Error("invalid request body, empty did or name") 821 - // w.WriteHeader(http.StatusBadRequest) 822 - // return 823 - // } 824 - // 825 - // var name string 826 - // if data.Name != "" { 827 - // name = data.Name 828 - // } else { 829 - // name = filepath.Base(source) 830 - // } 831 - // 832 - // relativeRepoPath := filepath.Join(did, name) 833 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 834 - // 835 - // err := git.Fork(repoPath, source) 836 - // if err != nil { 837 - // l.Error("forking repo", "error", err.Error()) 838 - // writeError(w, err.Error(), http.StatusInternalServerError) 839 - // return 840 - // } 841 - // 842 - // // add perms for this user to access the repo 843 - // err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 844 - // if err != nil { 845 - // l.Error("adding repo permissions", "error", err.Error()) 846 - // writeError(w, err.Error(), http.StatusInternalServerError) 847 - // return 848 - // } 849 - // 850 - // hook.SetupRepo( 851 - // hook.Config( 852 - // hook.WithScanPath(h.c.Repo.ScanPath), 853 - // hook.WithInternalApi(h.c.Server.InternalListenAddr), 854 - // ), 855 - // repoPath, 856 - // ) 857 - // 858 - // w.WriteHeader(http.StatusNoContent) 859 - // } 860 - 861 - // func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 862 - // l := h.l.With("handler", "RemoveRepo") 863 - // 864 - // data := struct { 865 - // Did string `json:"did"` 866 - // Name string `json:"name"` 867 - // }{} 868 - // 869 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 870 - // writeError(w, "invalid request body", http.StatusBadRequest) 871 - // return 872 - // } 873 - // 874 - // did := data.Did 875 - // name := data.Name 876 - // 877 - // if did == "" || name == "" { 878 - // l.Error("invalid request body, empty did or name") 879 - // w.WriteHeader(http.StatusBadRequest) 880 - // return 881 - // } 882 - // 883 - // relativeRepoPath := filepath.Join(did, name) 884 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 885 - // err := os.RemoveAll(repoPath) 886 - // if err != nil { 887 - // l.Error("removing repo", "error", err.Error()) 888 - // writeError(w, err.Error(), http.StatusInternalServerError) 889 - // return 890 - // } 891 - // 892 - // w.WriteHeader(http.StatusNoContent) 893 - // 894 - // } 895 - 896 - // func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 897 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 898 - // 899 - // data := types.MergeRequest{} 900 - // 901 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 902 - // writeError(w, err.Error(), http.StatusBadRequest) 903 - // h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 904 - // return 905 - // } 906 - // 907 - // mo := &git.MergeOptions{ 908 - // AuthorName: data.AuthorName, 909 - // AuthorEmail: data.AuthorEmail, 910 - // CommitBody: data.CommitBody, 911 - // CommitMessage: data.CommitMessage, 912 - // } 913 - // 914 - // patch := data.Patch 915 - // branch := data.Branch 916 - // gr, err := git.Open(path, branch) 917 - // if err != nil { 918 - // notFound(w) 919 - // return 920 - // } 921 - // 922 - // mo.FormatPatch = patchutil.IsFormatPatch(patch) 923 - // 924 - // if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 925 - // var mergeErr *git.ErrMerge 926 - // if errors.As(err, &mergeErr) { 927 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 928 - // for i, conflict := range mergeErr.Conflicts { 929 - // conflicts[i] = types.ConflictInfo{ 930 - // Filename: conflict.Filename, 931 - // Reason: conflict.Reason, 932 - // } 933 - // } 934 - // response := types.MergeCheckResponse{ 935 - // IsConflicted: true, 936 - // Conflicts: conflicts, 937 - // Message: mergeErr.Message, 938 - // } 939 - // writeConflict(w, response) 940 - // h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 941 - // } else { 942 - // writeError(w, err.Error(), http.StatusBadRequest) 943 - // h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 944 - // } 945 - // return 946 - // } 947 - // 948 - // w.WriteHeader(http.StatusOK) 949 - // } 950 - 951 - // func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 952 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 953 - // 954 - // var data struct { 955 - // Patch string `json:"patch"` 956 - // Branch string `json:"branch"` 957 - // } 958 - // 959 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 960 - // writeError(w, err.Error(), http.StatusBadRequest) 961 - // h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 962 - // return 963 - // } 964 - // 965 - // patch := data.Patch 966 - // branch := data.Branch 967 - // gr, err := git.Open(path, branch) 968 - // if err != nil { 969 - // notFound(w) 970 - // return 971 - // } 972 - // 973 - // err = gr.MergeCheck([]byte(patch), branch) 974 - // if err == nil { 975 - // response := types.MergeCheckResponse{ 976 - // IsConflicted: false, 977 - // } 978 - // writeJSON(w, response) 979 - // return 980 - // } 981 - // 982 - // var mergeErr *git.ErrMerge 983 - // if errors.As(err, &mergeErr) { 984 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 985 - // for i, conflict := range mergeErr.Conflicts { 986 - // conflicts[i] = types.ConflictInfo{ 987 - // Filename: conflict.Filename, 988 - // Reason: conflict.Reason, 989 - // } 990 - // } 991 - // response := types.MergeCheckResponse{ 992 - // IsConflicted: true, 993 - // Conflicts: conflicts, 994 - // Message: mergeErr.Message, 995 - // } 996 - // writeConflict(w, response) 997 - // h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 998 - // return 999 - // } 1000 - // writeError(w, err.Error(), http.StatusInternalServerError) 1001 - // h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1002 - // } 1003 - 1004 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1005 - rev1 := chi.URLParam(r, "rev1") 1006 - rev1, _ = url.PathUnescape(rev1) 1007 - 1008 - rev2 := chi.URLParam(r, "rev2") 1009 - rev2, _ = url.PathUnescape(rev2) 1010 - 1011 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1012 - 1013 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1014 - gr, err := git.PlainOpen(path) 1015 - if err != nil { 1016 - notFound(w) 1017 - return 1018 - } 1019 - 1020 - commit1, err := gr.ResolveRevision(rev1) 1021 - if err != nil { 1022 - l.Error("error resolving revision 1", "msg", err.Error()) 1023 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1024 - return 1025 - } 1026 - 1027 - commit2, err := gr.ResolveRevision(rev2) 1028 - if err != nil { 1029 - l.Error("error resolving revision 2", "msg", err.Error()) 1030 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1031 - return 1032 - } 1033 - 1034 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1035 - if err != nil { 1036 - l.Error("error comparing revisions", "msg", err.Error()) 1037 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1038 - return 1039 - } 1040 - 1041 - writeJSON(w, types.RepoFormatPatchResponse{ 1042 - Rev1: commit1.Hash.String(), 1043 - Rev2: commit2.Hash.String(), 1044 - FormatPatch: formatPatch, 1045 - Patch: rawPatch, 1046 - }) 1047 - } 1048 - 1049 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1050 - l := h.l.With("handler", "DefaultBranch") 1051 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1052 - 1053 - gr, err := git.Open(path, "") 1054 - if err != nil { 1055 - notFound(w) 1056 - return 1057 - } 1058 - 1059 - branch, err := gr.FindMainBranch() 1060 - if err != nil { 1061 - writeError(w, err.Error(), http.StatusInternalServerError) 1062 - l.Error("getting default branch", "error", err.Error()) 1063 - return 1064 - } 1065 - 1066 - writeJSON(w, types.RepoDefaultBranchResponse{ 1067 - Branch: branch, 1068 - }) 1069 - }
···
+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
···
··· 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 + }
-12
appview/pages/templates/knots/fragments/bannerRequiresUpgrade.html
··· 1 - {{ define "knots/fragments/bannerRequiresUpgrade" }} 2 - <div class="px-6 py-2"> 3 - The following knots that you administer will have to be upgraded to be compatible with the latest version of Tangled: 4 - <ul class="list-disc mx-12 my-2"> 5 - {{range $i, $r := .Registrations}} 6 - <li>{{ $r.Domain }}</li> 7 - {{ end }} 8 - </ul> 9 - Repositories hosted on these knots will not be accessible until upgraded. 10 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.8.0.md">Click to read the upgrade guide</a>. 11 - </div> 12 - {{ end }}
···
+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
···
··· 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 + }
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 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> 7 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> 41 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> 48 </div> 49 - {{ end }} 50 {{ end }} 51
··· 1 {{ define "repo/issues/fragments/editIssueComment" }} 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> 9 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 13 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" . }} 18 </div> 19 {{ end }} 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
··· 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
···
··· 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
···
··· 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 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 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> 37 {{ end }}
··· 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 + {{ template "repo/issues/fragments/putIssue" . }} 5 {{ end }}
+5
xrpc/errors/errors.go
··· 61 WithMessage("failed to access repository"), 62 ) 63 64 var AuthError = func(err error) XrpcError { 65 return NewXrpcError( 66 WithTag("Auth"),
··· 61 WithMessage("failed to access repository"), 62 ) 63 64 + var RefNotFoundError = NewXrpcError( 65 + WithTag("RefNotFound"), 66 + WithMessage("failed to access ref"), 67 + ) 68 + 69 var AuthError = func(err error) XrpcError { 70 return NewXrpcError( 71 WithTag("Auth"),
+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
···
··· 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
···
··· 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&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;" 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
···
··· 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
···
··· 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 }}
+1 -1
appview/pages/templates/repo/log.html
··· 2 3 {{ define "extrameta" }} 4 {{ $title := printf "commits &middot; %s" .RepoInfo.FullName }} 5 - {{ $url := printf "https://tangled.sh/%s/commits" .RepoInfo.FullName }} 6 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 {{ end }}
··· 2 3 {{ define "extrameta" }} 4 {{ $title := printf "commits &middot; %s" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/commits" .RepoInfo.FullName }} 6 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 {{ end }}
+2 -2
appview/pages/templates/repo/pipelines/pipelines.html
··· 2 3 {{ define "extrameta" }} 4 {{ $title := "pipelines"}} 5 - {{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }} 6 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 {{ end }} 8 ··· 60 <span class="inline-flex gap-2 items-center"> 61 <span class="font-bold">{{ $target }}</span> 62 {{ i "arrow-left" "size-4" }} 63 - {{ .Trigger.PRSourceBranch }} 64 <span class="text-sm font-mono"> 65 @ 66 <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
··· 2 3 {{ define "extrameta" }} 4 {{ $title := "pipelines"}} 5 + {{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }} 6 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 {{ end }} 8 ··· 60 <span class="inline-flex gap-2 items-center"> 61 <span class="font-bold">{{ $target }}</span> 62 {{ i "arrow-left" "size-4" }} 63 + {{ .Trigger.PRSourceBranch }} 64 <span class="text-sm font-mono"> 65 @ 66 <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
+10 -10
appview/pages/templates/repo/tags.html
··· 4 5 {{ define "extrameta" }} 6 {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/tags" .RepoInfo.FullName }} 8 - 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 ··· 26 27 <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 28 {{ if .Tag }} 29 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 30 class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 31 {{ slice .Tag.Target.String 0 8 }} 32 </a> ··· 48 </a> 49 <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 50 {{ if .Tag }} 51 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 52 class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 53 {{ i "git-commit-horizontal" "w-4 h-4" }} 54 {{ slice .Tag.Target.String 0 8 }} ··· 132 hx-target="this" 133 class="flex items-center gap-2 px-2"> 134 <div class="flex-grow"> 135 - <input type="file" 136 - name="artifact" 137 required 138 class="block py-2 px-0 w-full border-none 139 text-black dark:text-white ··· 148 </input> 149 </div> 150 <div class="flex justify-end"> 151 - <button 152 - type="submit" 153 - class="btn gap-2" 154 id="upload-btn-{{$unique}}" 155 title="Upload artifact"> 156 {{ i "upload" "w-4 h-4" }} 157 - <span class="hidden md:inline">upload</span> 158 </button> 159 </div> 160 </form>
··· 4 5 {{ define "extrameta" }} 6 {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 + {{ $url := printf "https://tangled.org/%s/tags" .RepoInfo.FullName }} 8 + 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 ··· 26 27 <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 28 {{ if .Tag }} 29 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 30 class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 31 {{ slice .Tag.Target.String 0 8 }} 32 </a> ··· 48 </a> 49 <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 50 {{ if .Tag }} 51 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 52 class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 53 {{ i "git-commit-horizontal" "w-4 h-4" }} 54 {{ slice .Tag.Target.String 0 8 }} ··· 132 hx-target="this" 133 class="flex items-center gap-2 px-2"> 134 <div class="flex-grow"> 135 + <input type="file" 136 + name="artifact" 137 required 138 class="block py-2 px-0 w-full border-none 139 text-black dark:text-white ··· 148 </input> 149 </div> 150 <div class="flex justify-end"> 151 + <button 152 + type="submit" 153 + class="btn gap-2" 154 id="upload-btn-{{$unique}}" 155 title="Upload artifact"> 156 {{ i "upload" "w-4 h-4" }} 157 + <span class="hidden md:inline">upload</span> 158 </button> 159 </div> 160 </form>
+1 -1
appview/pages/templates/strings/dashboard.html
··· 3 {{ define "extrameta" }} 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 }}" /> 8 {{ end }} 9
··· 3 {{ define "extrameta" }} 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.org/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 {{ end }} 9
+1 -1
appview/pages/templates/strings/string.html
··· 4 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 <meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" /> 6 <meta property="og:type" content="object" /> 7 - <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 {{ end }} 10
··· 4 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 <meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" /> 6 <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 {{ end }} 10
+1 -2
appview/pages/templates/timeline/fragments/hero.html
··· 22 </div> 23 24 <figure class="w-full hidden md:block md:w-auto"> 25 - <a href="https://tangled.sh/@tangled.sh/core" class="block"> 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 </a> 28 <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> ··· 31 </figure> 32 </div> 33 {{ end }} 34 -
··· 22 </div> 23 24 <figure class="w-full hidden md:block md:w-auto"> 25 + <a href="https://tangled.org/@tangled.org/core" class="block"> 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 </a> 28 <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> ··· 31 </figure> 32 </div> 33 {{ end }}
+4 -4
readme.md
··· 1 # tangled 2 3 Hello Tanglers! This is the codebase for 4 - [Tangled](https://tangled.sh)&mdash;a code collaboration platform built 5 on the [AT Protocol](https://atproto.com). 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 9 libera.chat](https://web.libera.chat/#tangled). 10 11 ## docs ··· 17 ## security 18 19 If you've identified a security issue in Tangled, please email 20 - [security@tangled.sh](mailto:security@tangled.sh) with details!
··· 1 # tangled 2 3 Hello Tanglers! This is the codebase for 4 + [Tangled](https://tangled.org)&mdash;a code collaboration platform built 5 on the [AT Protocol](https://atproto.com). 6 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 libera.chat](https://web.libera.chat/#tangled). 10 11 ## docs ··· 17 ## security 18 19 If you've identified a security issue in Tangled, please email 20 + [security@tangled.org](mailto:security@tangled.org) with details!
+1 -1
.air/knotserver.toml
··· 1 [build] 2 - cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 bin = ".bin/knot server" 4 root = "." 5
··· 1 [build] 2 + cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 bin = ".bin/knot server" 4 root = "." 5
+1 -1
appview/pipelines/router.go
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 8 ) 9 10 func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/middleware" 8 ) 9 10 func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
+4 -4
appview/serververify/verify.go
··· 6 "fmt" 7 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" 13 ) 14 15 var (
··· 6 "fmt" 7 8 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/rbac" 13 ) 14 15 var (
+1 -1
cmd/combinediff/main.go
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 9 ) 10 11 func main() {
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/patchutil" 9 ) 10 11 func main() {
+1 -1
cmd/interdiff/main.go
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 9 ) 10 11 func main() {
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/patchutil" 9 ) 10 11 func main() {
+1 -1
cmd/verifysig/main.go
··· 7 "os" 8 "strings" 9 10 - "tangled.sh/tangled.sh/core/crypto" 11 ) 12 13 func parseCommitObject(commitData string) (string, string, error) {
··· 7 "os" 8 "strings" 9 10 + "tangled.org/core/crypto" 11 ) 12 13 func parseCommitObject(commitData string) (string, string, error) {
+1 -1
crypto/verify.go
··· 9 10 "github.com/hiddeco/sshsig" 11 "golang.org/x/crypto/ssh" 12 - "tangled.sh/tangled.sh/core/types" 13 ) 14 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
··· 9 10 "github.com/hiddeco/sshsig" 11 "golang.org/x/crypto/ssh" 12 + "tangled.org/core/types" 13 ) 14 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
+2 -2
docs/knot-hosting.md
··· 19 First, clone this repository: 20 21 ``` 22 - git clone https://tangled.sh/@tangled.sh/core 23 ``` 24 25 Then, build the `knot` CLI. This is the knot administration and operation tool. ··· 130 131 You should now have a running knot server! You can finalize 132 your registration by hitting the `verify` button on the 133 - [/knots](https://tangled.sh/knots) page. This simply creates 134 a record on your PDS to announce the existence of the knot. 135 136 ### custom paths
··· 19 First, clone this repository: 20 21 ``` 22 + git clone https://tangled.org/@tangled.org/core 23 ``` 24 25 Then, build the `knot` CLI. This is the knot administration and operation tool. ··· 130 131 You should now have a running knot server! You can finalize 132 your registration by hitting the `verify` button on the 133 + [/knots](https://tangled.org/knots) page. This simply creates 134 a record on your PDS to announce the existence of the knot. 135 136 ### custom paths
+4 -5
docs/migrations.md
··· 14 For knots: 15 16 - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.sh/knots) and 18 hit the "retry" button to verify your knot 19 20 For spindles: 21 22 - Upgrade to latest tag (v1.9.0 or above) 23 - Head to the [spindle 24 - dashboard](https://tangled.sh/spindles) and hit the 25 "retry" button to verify your spindle 26 27 ## Upgrading from v1.7.x ··· 38 environment variable entirely 39 - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 your DID. You can find your DID in the 41 - [settings](https://tangled.sh/settings) page. 42 - Restart your knot once you have replaced the environment 43 variable 44 - - Head to the [knot dashboard](https://tangled.sh/knots) and 45 hit the "retry" button to verify your knot. This simply 46 writes a `sh.tangled.knot` record to your PDS. 47 ··· 57 }; 58 }; 59 ``` 60 -
··· 14 For knots: 15 16 - Upgrade to latest tag (v1.9.0 or above) 17 + - Head to the [knot dashboard](https://tangled.org/knots) and 18 hit the "retry" button to verify your knot 19 20 For spindles: 21 22 - Upgrade to latest tag (v1.9.0 or above) 23 - Head to the [spindle 24 + dashboard](https://tangled.org/spindles) and hit the 25 "retry" button to verify your spindle 26 27 ## Upgrading from v1.7.x ··· 38 environment variable entirely 39 - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 your DID. You can find your DID in the 41 + [settings](https://tangled.org/settings) page. 42 - Restart your knot once you have replaced the environment 43 variable 44 + - Head to the [knot dashboard](https://tangled.org/knots) and 45 hit the "retry" button to verify your knot. This simply 46 writes a `sh.tangled.knot` record to your PDS. 47 ··· 57 }; 58 }; 59 ```
+2 -2
eventconsumer/consumer.go
··· 9 "sync" 10 "time" 11 12 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 13 - "tangled.sh/tangled.sh/core/log" 14 15 "github.com/avast/retry-go/v4" 16 "github.com/gorilla/websocket"
··· 9 "sync" 10 "time" 11 12 + "tangled.org/core/eventconsumer/cursor" 13 + "tangled.org/core/log" 14 15 "github.com/avast/retry-go/v4" 16 "github.com/gorilla/websocket"
+1 -1
eventconsumer/cursor/redis.go
··· 5 "fmt" 6 "strconv" 7 8 - "tangled.sh/tangled.sh/core/appview/cache" 9 ) 10 11 const (
··· 5 "fmt" 6 "strconv" 7 8 + "tangled.org/core/appview/cache" 9 ) 10 11 const (
+2 -2
guard/guard.go
··· 15 "github.com/bluesky-social/indigo/atproto/identity" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/urfave/cli/v3" 18 - "tangled.sh/tangled.sh/core/idresolver" 19 - "tangled.sh/tangled.sh/core/log" 20 ) 21 22 func Command() *cli.Command {
··· 15 "github.com/bluesky-social/indigo/atproto/identity" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/urfave/cli/v3" 18 + "tangled.org/core/idresolver" 19 + "tangled.org/core/log" 20 ) 21 22 func Command() *cli.Command {
+1 -1
keyfetch/keyfetch.go
··· 10 "strings" 11 12 "github.com/urfave/cli/v3" 13 - "tangled.sh/tangled.sh/core/log" 14 ) 15 16 func Command() *cli.Command {
··· 10 "strings" 11 12 "github.com/urfave/cli/v3" 13 + "tangled.org/core/log" 14 ) 15 16 func Command() *cli.Command {
+1 -1
knotserver/db/events.go
··· 4 "fmt" 5 "time" 6 7 - "tangled.sh/tangled.sh/core/notifier" 8 ) 9 10 type Event struct {
··· 4 "fmt" 5 "time" 6 7 + "tangled.org/core/notifier" 8 ) 9 10 type Event struct {
+2 -2
knotserver/git/diff.go
··· 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 "github.com/go-git/go-git/v5/plumbing" 14 "github.com/go-git/go-git/v5/plumbing/object" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 - "tangled.sh/tangled.sh/core/types" 17 ) 18 19 func (g *GitRepo) Diff() (*types.NiceDiff, error) {
··· 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 "github.com/go-git/go-git/v5/plumbing" 14 "github.com/go-git/go-git/v5/plumbing/object" 15 + "tangled.org/core/patchutil" 16 + "tangled.org/core/types" 17 ) 18 19 func (g *GitRepo) Diff() (*types.NiceDiff, error) {
+1 -1
knotserver/git/post_receive.go
··· 9 "strings" 10 "time" 11 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 14 "github.com/go-git/go-git/v5/plumbing" 15 )
··· 9 "strings" 10 "time" 11 12 + "tangled.org/core/api/tangled" 13 14 "github.com/go-git/go-git/v5/plumbing" 15 )
+3 -3
knotserver/xrpc/delete_repo.go
··· 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "github.com/bluesky-social/indigo/xrpc" 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" 17 ) 18 19 func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
··· 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "github.com/bluesky-social/indigo/xrpc" 13 securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/rbac" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 ) 18 19 func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/list_keys.go
··· 4 "net/http" 5 "strconv" 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
··· 4 "net/http" 5 "strconv" 6 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/owner.go
··· 3 import ( 4 "net/http" 5 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 8 ) 9 10 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
··· 3 import ( 4 "net/http" 5 6 + "tangled.org/core/api/tangled" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 ) 9 10 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_branch.go
··· 5 "net/url" 6 "time" 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" 11 ) 12 13 func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
··· 5 "net/url" 6 "time" 7 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/knotserver/git" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 ) 12 13 func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_compare.go
··· 4 "fmt" 5 "net/http" 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" 10 ) 11 12 func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
··· 4 "fmt" 5 "net/http" 6 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 ) 11 12 func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_diff.go
··· 3 import ( 4 "net/http" 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" 9 ) 10 11 func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
··· 3 import ( 4 "net/http" 5 6 + "tangled.org/core/knotserver/git" 7 + "tangled.org/core/types" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_languages.go
··· 6 "net/http" 7 "time" 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" 12 ) 13 14 func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
··· 6 "net/http" 7 "time" 8 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/knotserver/git" 11 + xrpcerr "tangled.org/core/xrpc/errors" 12 ) 13 14 func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_tags.go
··· 7 "github.com/go-git/go-git/v5/plumbing" 8 "github.com/go-git/go-git/v5/plumbing/object" 9 10 - "tangled.sh/tangled.sh/core/knotserver/git" 11 - "tangled.sh/tangled.sh/core/types" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 ) 14 15 func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
··· 7 "github.com/go-git/go-git/v5/plumbing" 8 "github.com/go-git/go-git/v5/plumbing/object" 9 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 15 func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/version.go
··· 5 "net/http" 6 "runtime/debug" 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 ) 10 11 // version is set during build time. ··· 24 var modified bool 25 26 for _, mod := range info.Deps { 27 - if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 28 modVer = mod.Version 29 break 30 }
··· 5 "net/http" 6 "runtime/debug" 7 8 + "tangled.org/core/api/tangled" 9 ) 10 11 // version is set during build time. ··· 24 var modified bool 25 26 for _, mod := range info.Deps { 27 + if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" { 28 modVer = mod.Version 29 break 30 }
+1 -1
lexicon-build-config.json
··· 3 "package": "tangled", 4 "prefix": "sh.tangled", 5 "outdir": "api/tangled", 6 - "import": "tangled.sh/tangled.sh/core/api/tangled", 7 "gen-server": true 8 } 9 ]
··· 3 "package": "tangled", 4 "prefix": "sh.tangled", 5 "outdir": "api/tangled", 6 + "import": "tangled.org/core/api/tangled", 7 "gen-server": true 8 } 9 ]
+1 -1
patchutil/patchutil.go
··· 10 "strings" 11 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 - "tangled.sh/tangled.sh/core/types" 14 ) 15 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
··· 10 "strings" 11 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 + "tangled.org/core/types" 14 ) 15 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
+4 -4
spindle/db/events.go
··· 5 "fmt" 6 "time" 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" 12 ) 13 14 type Event struct {
··· 5 "fmt" 6 "time" 7 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/notifier" 10 + "tangled.org/core/spindle/models" 11 + "tangled.org/core/tid" 12 ) 13 14 type Event struct {
+2 -2
spindle/engines/nixery/setup_steps.go
··· 5 "path" 6 "strings" 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 10 ) 11 12 func nixConfStep() Step {
··· 5 "path" 6 "strings" 7 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/workflow" 10 ) 11 12 func nixConfStep() Step {
+9 -9
spindle/xrpc/xrpc.go
··· 8 9 "github.com/go-chi/chi/v5" 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" 20 ) 21 22 const ActorDid string = "ActorDid"
··· 8 9 "github.com/go-chi/chi/v5" 10 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 ) 21 22 const ActorDid string = "ActorDid"
+1 -1
workflow/compile_test.go
··· 5 "testing" 6 7 "github.com/stretchr/testify/assert" 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 ) 10 11 var trigger = tangled.Pipeline_TriggerMetadata{
··· 5 "testing" 6 7 "github.com/stretchr/testify/assert" 8 + "tangled.org/core/api/tangled" 9 ) 10 11 var trigger = tangled.Pipeline_TriggerMetadata{
+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
···
··· 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
···
··· 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 + }
+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 + }
+3 -2
api/tangled/tangledrepo.go
··· 22 Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 23 // knot: knot where the repo was created 24 Knot string `json:"knot" cborgen:"knot"` 25 // name: name of the repo 26 - Name string `json:"name" cborgen:"name"` 27 - Owner string `json:"owner" cborgen:"owner"` 28 // source: source of the repo 29 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 30 // spindle: CI runner to send jobs to and receive results from
··· 22 Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 23 // knot: knot where the repo was created 24 Knot string `json:"knot" cborgen:"knot"` 25 + // labels: List of labels that this repo subscribes to 26 + Labels []string `json:"labels,omitempty" cborgen:"labels,omitempty"` 27 // name: name of the repo 28 + Name string `json:"name" cborgen:"name"` 29 // source: source of the repo 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 31 // spindle: CI runner to send jobs to and receive results from
+8 -5
lexicons/repo/repo.json
··· 12 "required": [ 13 "name", 14 "knot", 15 - "owner", 16 "createdAt" 17 ], 18 "properties": { ··· 20 "type": "string", 21 "description": "name of the repo" 22 }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 "knot": { 28 "type": "string", 29 "description": "knot where the repo was created" ··· 42 "format": "uri", 43 "description": "source of the repo" 44 }, 45 "createdAt": { 46 "type": "string", 47 "format": "datetime"
··· 12 "required": [ 13 "name", 14 "knot", 15 "createdAt" 16 ], 17 "properties": { ··· 19 "type": "string", 20 "description": "name of the repo" 21 }, 22 "knot": { 23 "type": "string", 24 "description": "knot where the repo was created" ··· 37 "format": "uri", 38 "description": "source of the repo" 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 + }, 48 "createdAt": { 49 "type": "string", 50 "format": "datetime"
+1 -1
spindle/xrpc/add_secret.go
··· 62 } 63 64 repo := resp.Value.Val.(*tangled.Repo) 65 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 66 if err != nil { 67 fail(xrpcerr.GenericError(err)) 68 return
··· 62 } 63 64 repo := resp.Value.Val.(*tangled.Repo) 65 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 66 if err != nil { 67 fail(xrpcerr.GenericError(err)) 68 return
+1 -1
spindle/xrpc/list_secrets.go
··· 57 } 58 59 repo := resp.Value.Val.(*tangled.Repo) 60 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 61 if err != nil { 62 fail(xrpcerr.GenericError(err)) 63 return
··· 57 } 58 59 repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 if err != nil { 62 fail(xrpcerr.GenericError(err)) 63 return
+1 -1
spindle/xrpc/remove_secret.go
··· 56 } 57 58 repo := resp.Value.Val.(*tangled.Repo) 59 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 if err != nil { 61 fail(xrpcerr.GenericError(err)) 62 return
··· 56 } 57 58 repo := resp.Value.Val.(*tangled.Repo) 59 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 60 if err != nil { 61 fail(xrpcerr.GenericError(err)) 62 return
+6
appview/pages/templates/repo/fragments/colorBall.html
···
··· 1 + {{ define "repo/fragments/colorBall" }} 2 + <div 3 + class="size-2 rounded-full {{ .classes }}" 4 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ .color }} 70%, white), {{ .color }} 30%, color-mix(in srgb, {{ .color }} 85%, black));" 5 + ></div> 6 + {{ end }}
+1 -1
appview/pages/templates/repo/index.html
··· 49 <div 50 class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 > 52 - {{ template "repo/fragments/languageBall" $value.Name }} 53 <div>{{ or $value.Name "Other" }} 54 <span class="text-gray-500 dark:text-gray-400"> 55 {{ if lt $value.Percentage 0.05 }}
··· 49 <div 50 class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 > 52 + {{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }} 53 <div>{{ or $value.Name "Other" }} 54 <span class="text-gray-500 dark:text-gray-400"> 55 {{ if lt $value.Percentage 0.05 }}
+1 -1
appview/pages/templates/user/overview.html
··· 73 {{ with .Repo.RepoStats }} 74 {{ with .Language }} 75 <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 - {{ template "repo/fragments/languageBall" . }} 77 <span>{{ . }}</span> 78 </div> 79 {{end }}
··· 73 {{ with .Repo.RepoStats }} 74 {{ with .Language }} 75 <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 77 <span>{{ . }}</span> 78 </div> 79 {{end }}
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 109 hx-swap="none" 110 class="flex flex-col gap-2" 111 > 112 - <p class="uppercase p-0">ADD SECRET</p> 113 <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 <input 115 type="text"
··· 109 hx-swap="none" 110 class="flex flex-col gap-2" 111 > 112 + <p class="uppercase p-0 font-bold">ADD SECRET</p> 113 <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 <input 115 type="text"
+6
appview/pages/templates/labels/fragments/labelDef.html
···
··· 1 + {{ define "labels/fragments/labelDef" }} 2 + <span class="flex items-center gap-2 font-normal normal-case"> 3 + {{ template "repo/fragments/colorBall" (dict "color" .GetColor) }} 4 + {{ .Name }} 5 + </span> 6 + {{ end }}
+6 -8
appview/pages/templates/layouts/repobase.html
··· 41 {{ template "repo/fragments/repoDescription" . }} 42 </section> 43 44 - <section 45 - class="w-full flex flex-col" 46 - > 47 <nav class="w-full pl-4 overflow-auto"> 48 <div class="flex z-60"> 49 {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} ··· 80 {{ end }} 81 </div> 82 </nav> 83 - <section 84 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 85 - > 86 {{ block "repoContent" . }}{{ end }} 87 - </section> 88 - {{ block "repoAfter" . }}{{ end }} 89 </section> 90 {{ end }}
··· 41 {{ template "repo/fragments/repoDescription" . }} 42 </section> 43 44 + <section class="w-full flex flex-col" > 45 <nav class="w-full pl-4 overflow-auto"> 46 <div class="flex z-60"> 47 {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} ··· 78 {{ end }} 79 </div> 80 </nav> 81 + {{ block "repoContentLayout" . }} 82 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 83 {{ block "repoContent" . }}{{ end }} 84 + </section> 85 + {{ block "repoAfter" . }}{{ end }} 86 + {{ end }} 87 </section> 88 {{ end }}
+4 -4
appview/pages/templates/repo/issues/fragments/commentList.html
··· 3 {{ range $item := .CommentList }} 4 {{ template "commentListing" (list $ .) }} 5 {{ end }} 6 - <div> 7 {{ end }} 8 9 {{ define "commentListing" }} ··· 16 "Issue" $root.Issue 17 "Comment" $comment.Self) }} 18 19 - <div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm"> 20 {{ template "topLevelComment" $params }} 21 22 - <div class="relative ml-4 border-l border-gray-300 dark:border-gray-700"> 23 {{ range $index, $reply := $comment.Replies }} 24 <div class="relative "> 25 <!-- Horizontal connector --> 26 - <div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div> 27 28 <div class="pl-2"> 29 {{
··· 3 {{ range $item := .CommentList }} 4 {{ template "commentListing" (list $ .) }} 5 {{ end }} 6 + </div> 7 {{ end }} 8 9 {{ define "commentListing" }} ··· 16 "Issue" $root.Issue 17 "Comment" $comment.Self) }} 18 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 {{ template "topLevelComment" $params }} 21 22 + <div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700"> 23 {{ range $index, $reply := $comment.Replies }} 24 <div class="relative "> 25 <!-- Horizontal connector --> 26 + <div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div> 27 28 <div class="pl-2"> 29 {{
-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
···
··· 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
···
··· 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
···
··· 1 + {{ define "repo/fragments/labelSectionHeaderText" }} 2 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span> 3 + {{ end }}
+138 -86
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
··· 1 {{ define "repo/settings/fragments/addLabelDefModal" }} 2 - <form 3 - hx-put="/{{ $.RepoInfo.FullName }}/settings/label" 4 - hx-indicator="#spinner" 5 - hx-swap="none" 6 - 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> 10 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"/> 14 </div> 15 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> 42 </div> 43 44 - <!-- Scope --> 45 <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> 51 </div> 52 53 - <!-- Color --> 54 <div class="w-full"> 55 <label for="color">Color</label> 56 <div class="grid grid-cols-4 grid-rows-2 place-items-center"> ··· 63 {{ end }} 64 </div> 65 </div> 66 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> 73 </div> 74 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> 90 </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> 112 {{ end }} 113
··· 1 {{ define "repo/settings/fragments/addLabelDefModal" }} 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"> 5 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" . }} 18 </div> 19 20 + <!-- Key-value Labels Content - direct sibling --> 21 + <div class="mt-4 hidden peer-checked/kv:block col-span-full"> 22 + {{ template "kvLabelDef" . }} 23 </div> 24 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" }} 77 <div class="w-full"> 78 + <label for="name">Name</label> 79 + <input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/> 80 </div> 81 + {{ end }} 82 83 + {{ define "colorInput" }} 84 <div class="w-full"> 85 <label for="color">Color</label> 86 <div class="grid grid-cols-4 grid-rows-2 place-items-center"> ··· 93 {{ end }} 94 </div> 95 </div> 96 + {{ end }} 97 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> 119 </div> 120 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> 127 </div> 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> 143 {{ end }} 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
··· 1 {{ define "repo/settings/fragments/labelListing" }} 2 {{ $root := index . 0 }} 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"> 8 {{ $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> 18 </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 </div> 34 {{ end }}
··· 1 {{ define "repo/settings/fragments/labelListing" }} 2 {{ $root := index . 0 }} 3 {{ $label := index . 1 }} 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 }} 10 {{ $label.ValueType.Type }} type 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 ", " }} 30 </div> 31 </div> 32 {{ end }}
+9
consts/consts.go
···
··· 1 + package consts 2 + 3 + const ( 4 + TangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 5 + IcyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 6 + 7 + DefaultSpindle = "spindle.tangled.sh" 8 + DefaultKnot = "knot1.tangled.sh" 9 + )
+5 -25
appview/db/artifact.go
··· 5 "strings" 6 "time" 7 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 "github.com/go-git/go-git/v5/plumbing" 10 "github.com/ipfs/go-cid" 11 - "tangled.org/core/api/tangled" 12 ) 13 14 - type Artifact struct { 15 - Id uint64 16 - Did string 17 - Rkey string 18 - 19 - RepoAt syntax.ATURI 20 - Tag plumbing.Hash 21 - CreatedAt time.Time 22 - 23 - BlobCid cid.Cid 24 - Name string 25 - Size uint64 26 - MimeType string 27 - } 28 - 29 - func (a *Artifact) ArtifactAt() syntax.ATURI { 30 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey)) 31 - } 32 - 33 - func AddArtifact(e Execer, artifact Artifact) error { 34 _, err := e.Exec( 35 `insert or ignore into artifacts ( 36 did, ··· 57 return err 58 } 59 60 - func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) { 61 - var artifacts []Artifact 62 63 var conditions []string 64 var args []any ··· 94 defer rows.Close() 95 96 for rows.Next() { 97 - var artifact Artifact 98 var createdAt string 99 var tag []byte 100 var blobCid string
··· 5 "strings" 6 "time" 7 8 "github.com/go-git/go-git/v5/plumbing" 9 "github.com/ipfs/go-cid" 10 + "tangled.org/core/appview/models" 11 ) 12 13 + func AddArtifact(e Execer, artifact models.Artifact) error { 14 _, err := e.Exec( 15 `insert or ignore into artifacts ( 16 did, ··· 37 return err 38 } 39 40 + func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) { 41 + var artifacts []models.Artifact 42 43 var conditions []string 44 var args []any ··· 74 defer rows.Close() 75 76 for rows.Next() { 77 + var artifact models.Artifact 78 var createdAt string 79 var tag []byte 80 var blobCid string
+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
···
··· 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
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Email struct { 8 + ID int64 9 + Did string 10 + Address string 11 + Verified bool 12 + Primary bool 13 + VerificationCode string 14 + LastSent *time.Time 15 + CreatedAt time.Time 16 + }
+26 -57
appview/db/follow.go
··· 5 "log" 6 "strings" 7 "time" 8 - ) 9 10 - type Follow struct { 11 - UserDid string 12 - SubjectDid string 13 - FollowedAt time.Time 14 - Rkey string 15 - } 16 17 - func AddFollow(e Execer, follow *Follow) error { 18 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 19 _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 20 return err 21 } 22 23 // Get a follow record 24 - func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) { 25 query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 26 row := e.QueryRow(query, userDid, subjectDid) 27 28 - var follow Follow 29 var followedAt string 30 err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 31 if err != nil { ··· 55 return err 56 } 57 58 - type FollowStats struct { 59 - Followers int64 60 - Following int64 61 - } 62 - 63 - func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 var followers, following int64 65 err := e.QueryRow( 66 `SELECT ··· 68 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 FROM follows;`, did, did).Scan(&followers, &following) 70 if err != nil { 71 - return FollowStats{}, err 72 } 73 - return FollowStats{ 74 Followers: followers, 75 Following: following, 76 }, nil 77 } 78 79 - func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 if len(dids) == 0 { 81 return nil, nil 82 } ··· 112 ) g on f.did = g.did`, 113 placeholderStr, placeholderStr) 114 115 - result := make(map[string]FollowStats) 116 117 rows, err := e.Query(query, args...) 118 if err != nil { ··· 126 if err := rows.Scan(&did, &followers, &following); err != nil { 127 return nil, err 128 } 129 - result[did] = FollowStats{ 130 Followers: followers, 131 Following: following, 132 } ··· 134 135 for _, did := range dids { 136 if _, exists := result[did]; !exists { 137 - result[did] = FollowStats{ 138 Followers: 0, 139 Following: 0, 140 } ··· 144 return result, nil 145 } 146 147 - func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 148 - var follows []Follow 149 150 var conditions []string 151 var args []any ··· 177 return nil, err 178 } 179 for rows.Next() { 180 - var follow Follow 181 var followedAt string 182 err := rows.Scan( 183 &follow.UserDid, ··· 200 return follows, nil 201 } 202 203 - func GetFollowers(e Execer, did string) ([]Follow, error) { 204 return GetFollows(e, 0, FilterEq("subject_did", did)) 205 } 206 207 - func GetFollowing(e Execer, did string) ([]Follow, error) { 208 return GetFollows(e, 0, FilterEq("user_did", did)) 209 } 210 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) { 233 if len(subjectDids) == 0 || userDid == "" { 234 - return make(map[string]FollowStatus), nil 235 } 236 237 - result := make(map[string]FollowStatus) 238 239 for _, subjectDid := range subjectDids { 240 if userDid == subjectDid { 241 - result[subjectDid] = IsSelf 242 } else { 243 - result[subjectDid] = IsNotFollowing 244 } 245 } 246 ··· 281 if err := rows.Scan(&subjectDid); err != nil { 282 return nil, err 283 } 284 - result[subjectDid] = IsFollowing 285 } 286 287 return result, nil 288 } 289 290 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 291 statuses, err := getFollowStatuses(e, userDid, []string{subjectDid}) 292 if err != nil { 293 - return IsNotFollowing 294 } 295 return statuses[subjectDid] 296 } 297 298 - func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) { 299 return getFollowStatuses(e, userDid, subjectDids) 300 }
··· 5 "log" 6 "strings" 7 "time" 8 9 + "tangled.org/core/appview/models" 10 + ) 11 12 + func AddFollow(e Execer, follow *models.Follow) error { 13 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 14 _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 15 return err 16 } 17 18 // Get a follow record 19 + func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { 20 query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 21 row := e.QueryRow(query, userDid, subjectDid) 22 23 + var follow models.Follow 24 var followedAt string 25 err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 26 if err != nil { ··· 50 return err 51 } 52 53 + func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) { 54 var followers, following int64 55 err := e.QueryRow( 56 `SELECT ··· 58 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 59 FROM follows;`, did, did).Scan(&followers, &following) 60 if err != nil { 61 + return models.FollowStats{}, err 62 } 63 + return models.FollowStats{ 64 Followers: followers, 65 Following: following, 66 }, nil 67 } 68 69 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) { 70 if len(dids) == 0 { 71 return nil, nil 72 } ··· 102 ) g on f.did = g.did`, 103 placeholderStr, placeholderStr) 104 105 + result := make(map[string]models.FollowStats) 106 107 rows, err := e.Query(query, args...) 108 if err != nil { ··· 116 if err := rows.Scan(&did, &followers, &following); err != nil { 117 return nil, err 118 } 119 + result[did] = models.FollowStats{ 120 Followers: followers, 121 Following: following, 122 } ··· 124 125 for _, did := range dids { 126 if _, exists := result[did]; !exists { 127 + result[did] = models.FollowStats{ 128 Followers: 0, 129 Following: 0, 130 } ··· 134 return result, nil 135 } 136 137 + func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) { 138 + var follows []models.Follow 139 140 var conditions []string 141 var args []any ··· 167 return nil, err 168 } 169 for rows.Next() { 170 + var follow models.Follow 171 var followedAt string 172 err := rows.Scan( 173 &follow.UserDid, ··· 190 return follows, nil 191 } 192 193 + func GetFollowers(e Execer, did string) ([]models.Follow, error) { 194 return GetFollows(e, 0, FilterEq("subject_did", did)) 195 } 196 197 + func GetFollowing(e Execer, did string) ([]models.Follow, error) { 198 return GetFollows(e, 0, FilterEq("user_did", did)) 199 } 200 201 + func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 202 if len(subjectDids) == 0 || userDid == "" { 203 + return make(map[string]models.FollowStatus), nil 204 } 205 206 + result := make(map[string]models.FollowStatus) 207 208 for _, subjectDid := range subjectDids { 209 if userDid == subjectDid { 210 + result[subjectDid] = models.IsSelf 211 } else { 212 + result[subjectDid] = models.IsNotFollowing 213 } 214 } 215 ··· 250 if err := rows.Scan(&subjectDid); err != nil { 251 return nil, err 252 } 253 + result[subjectDid] = models.IsFollowing 254 } 255 256 return result, nil 257 } 258 259 + func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus { 260 statuses, err := getFollowStatuses(e, userDid, []string{subjectDid}) 261 if err != nil { 262 + return models.IsNotFollowing 263 } 264 return statuses[subjectDid] 265 } 266 267 + func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 268 return getFollowStatuses(e, userDid, subjectDids) 269 }
+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
··· 7 "strings" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/appview/db" 11 "tangled.org/core/appview/state/userutil" 12 ) 13 ··· 60 Spindle string 61 RepoAt syntax.ATURI 62 IsStarred bool 63 - Stats db.RepoStats 64 Roles RolesInRepo 65 - Source *db.Repo 66 SourceHandle string 67 Ref string 68 DisableFork bool
··· 7 "strings" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/appview/models" 11 "tangled.org/core/appview/state/userutil" 12 ) 13 ··· 60 Spindle string 61 RepoAt syntax.ATURI 62 IsStarred bool 63 + Stats models.RepoStats 64 Roles RolesInRepo 65 + Source *models.Repo 66 SourceHandle string 67 Ref string 68 DisableFork bool
+4 -4
appview/state/git_http.go
··· 8 9 "github.com/bluesky-social/indigo/atproto/identity" 10 "github.com/go-chi/chi/v5" 11 - "tangled.org/core/appview/db" 12 ) 13 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 - repo := r.Context().Value("repo").(*db.Repo) 17 18 scheme := "https" 19 if s.config.Core.Dev { ··· 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 return 33 } 34 - repo := r.Context().Value("repo").(*db.Repo) 35 36 scheme := "https" 37 if s.config.Core.Dev { ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 - repo := r.Context().Value("repo").(*db.Repo) 52 53 scheme := "https" 54 if s.config.Core.Dev {
··· 8 9 "github.com/bluesky-social/indigo/atproto/identity" 10 "github.com/go-chi/chi/v5" 11 + "tangled.org/core/appview/models" 12 ) 13 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 + repo := r.Context().Value("repo").(*models.Repo) 17 18 scheme := "https" 19 if s.config.Core.Dev { ··· 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 return 33 } 34 + repo := r.Context().Value("repo").(*models.Repo) 35 36 scheme := "https" 37 if s.config.Core.Dev { ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 + repo := r.Context().Value("repo").(*models.Repo) 52 53 scheme := "https" 54 if s.config.Core.Dev {
+15 -200
appview/db/issues.go
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/models" 15 "tangled.org/core/appview/pagination" 16 ) 17 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 { 203 // ensure sequence exists 204 _, err := tx.Exec(` 205 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) ··· 234 } 235 } 236 237 - func createNewIssue(tx *sql.Tx, issue *Issue) error { 238 // get next issue_id 239 var newIssueId int 240 err := tx.QueryRow(` ··· 257 return row.Scan(&issue.Id, &issue.IssueId) 258 } 259 260 - func updateIssue(tx *sql.Tx, issue *Issue) error { 261 // update existing issue 262 _, err := tx.Exec(` 263 update issues ··· 267 return err 268 } 269 270 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 271 - issueMap := make(map[string]*Issue) // at-uri -> issue 272 273 var conditions []string 274 var args []any ··· 323 defer rows.Close() 324 325 for rows.Next() { 326 - var issue Issue 327 var createdAt string 328 var editedAt, deletedAt sql.Null[string] 329 var rowNum int64 ··· 416 } 417 } 418 419 - var issues []Issue 420 for _, i := range issueMap { 421 issues = append(issues, *i) 422 } ··· 428 return issues, nil 429 } 430 431 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 432 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 433 } 434 435 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 436 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 437 row := e.QueryRow(query, repoAt, issueId) 438 439 - var issue Issue 440 var createdAt string 441 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 442 if err != nil { ··· 452 return &issue, nil 453 } 454 455 - func AddIssueComment(e Execer, c IssueComment) (int64, error) { 456 result, err := e.Exec( 457 `insert into issue_comments ( 458 did, ··· 514 return err 515 } 516 517 - func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 518 - var comments []IssueComment 519 520 var conditions []string 521 var args []any ··· 551 } 552 553 for rows.Next() { 554 - var comment IssueComment 555 var created string 556 var rkey, edited, deleted, replyTo sql.Null[string] 557 err := rows.Scan( ··· 670 671 var count models.IssueCount 672 if err := row.Scan(&count.Open, &count.Closed); err != nil { 673 - return models.IssueCount{0, 0}, err 674 } 675 676 return count, nil
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 "tangled.org/core/appview/pagination" 15 ) 16 17 + func PutIssue(tx *sql.Tx, issue *models.Issue) error { 18 // ensure sequence exists 19 _, err := tx.Exec(` 20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) ··· 49 } 50 } 51 52 + func createNewIssue(tx *sql.Tx, issue *models.Issue) error { 53 // get next issue_id 54 var newIssueId int 55 err := tx.QueryRow(` ··· 72 return row.Scan(&issue.Id, &issue.IssueId) 73 } 74 75 + func updateIssue(tx *sql.Tx, issue *models.Issue) error { 76 // update existing issue 77 _, err := tx.Exec(` 78 update issues ··· 82 return err 83 } 84 85 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 86 + issueMap := make(map[string]*models.Issue) // at-uri -> issue 87 88 var conditions []string 89 var args []any ··· 138 defer rows.Close() 139 140 for rows.Next() { 141 + var issue models.Issue 142 var createdAt string 143 var editedAt, deletedAt sql.Null[string] 144 var rowNum int64 ··· 231 } 232 } 233 234 + var issues []models.Issue 235 for _, i := range issueMap { 236 issues = append(issues, *i) 237 } ··· 243 return issues, nil 244 } 245 246 + func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248 } 249 250 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 251 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 252 row := e.QueryRow(query, repoAt, issueId) 253 254 + var issue models.Issue 255 var createdAt string 256 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 257 if err != nil { ··· 267 return &issue, nil 268 } 269 270 + func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 271 result, err := e.Exec( 272 `insert into issue_comments ( 273 did, ··· 329 return err 330 } 331 332 + func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 333 + var comments []models.IssueComment 334 335 var conditions []string 336 var args []any ··· 366 } 367 368 for rows.Next() { 369 + var comment models.IssueComment 370 var created string 371 var rkey, edited, deleted, replyTo sql.Null[string] 372 err := rows.Scan( ··· 485 486 var count models.IssueCount 487 if err := row.Scan(&count.Open, &count.Closed); err != nil { 488 + return models.IssueCount{}, err 489 } 490 491 return count, nil
+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
···
··· 1 + package models 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + type RepoLanguage struct { 8 + Id int64 9 + RepoAt syntax.ATURI 10 + Ref string 11 + IsDefaultRef bool 12 + Language string 13 + Bytes int64 14 + }
-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
···
··· 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
··· 6 "strings" 7 "time" 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" 13 ) 14 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 136 137 var conditions []string 138 var args []any ··· 156 defer rows.Close() 157 158 for rows.Next() { 159 - var pipeline Pipeline 160 var createdAt string 161 err = rows.Scan( 162 &pipeline.Id, ··· 185 return pipelines, nil 186 } 187 188 - func AddPipeline(e Execer, pipeline Pipeline) error { 189 args := []any{ 190 pipeline.Rkey, 191 pipeline.Knot, ··· 216 return err 217 } 218 219 - func AddTrigger(e Execer, trigger Trigger) (int64, error) { 220 args := []any{ 221 trigger.Kind, 222 trigger.PushRef, ··· 252 return res.LastInsertId() 253 } 254 255 - func AddPipelineStatus(e Execer, status PipelineStatus) error { 256 args := []any{ 257 status.Spindle, 258 status.Rkey, ··· 290 291 // this is a mega query, but the most useful one: 292 // get N pipelines, for each one get the latest status of its N workflows 293 - func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) { 294 var conditions []string 295 var args []any 296 for _, filter := range filters { ··· 335 } 336 defer rows.Close() 337 338 - pipelines := make(map[string]Pipeline) 339 for rows.Next() { 340 - var p Pipeline 341 - var t Trigger 342 var created string 343 344 err := rows.Scan( ··· 370 371 t.Id = p.TriggerId 372 p.Trigger = &t 373 - p.Statuses = make(map[string]WorkflowStatus) 374 375 k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 376 pipelines[k] = p ··· 409 defer rows.Close() 410 411 for rows.Next() { 412 - var ps PipelineStatus 413 var created string 414 415 err := rows.Scan( ··· 442 } 443 statuses, _ := pipeline.Statuses[ps.Workflow] 444 if !ok { 445 - pipeline.Statuses[ps.Workflow] = WorkflowStatus{} 446 } 447 448 // append ··· 453 pipelines[key] = pipeline 454 } 455 456 - var all []Pipeline 457 for _, p := range pipelines { 458 for _, s := range p.Statuses { 459 - slices.SortFunc(s.Data, func(a, b PipelineStatus) int { 460 if a.Created.After(b.Created) { 461 return 1 462 } ··· 476 } 477 478 // sort pipelines by date 479 - slices.SortFunc(all, func(a, b Pipeline) int { 480 if a.Created.After(b.Created) { 481 return -1 482 }
··· 6 "strings" 7 "time" 8 9 + "tangled.org/core/appview/models" 10 ) 11 12 + func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 13 + var pipelines []models.Pipeline 14 15 var conditions []string 16 var args []any ··· 34 defer rows.Close() 35 36 for rows.Next() { 37 + var pipeline models.Pipeline 38 var createdAt string 39 err = rows.Scan( 40 &pipeline.Id, ··· 63 return pipelines, nil 64 } 65 66 + func AddPipeline(e Execer, pipeline models.Pipeline) error { 67 args := []any{ 68 pipeline.Rkey, 69 pipeline.Knot, ··· 94 return err 95 } 96 97 + func AddTrigger(e Execer, trigger models.Trigger) (int64, error) { 98 args := []any{ 99 trigger.Kind, 100 trigger.PushRef, ··· 130 return res.LastInsertId() 131 } 132 133 + func AddPipelineStatus(e Execer, status models.PipelineStatus) error { 134 args := []any{ 135 status.Spindle, 136 status.Rkey, ··· 168 169 // this is a mega query, but the most useful one: 170 // get N pipelines, for each one get the latest status of its N workflows 171 + func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 172 var conditions []string 173 var args []any 174 for _, filter := range filters { ··· 213 } 214 defer rows.Close() 215 216 + pipelines := make(map[string]models.Pipeline) 217 for rows.Next() { 218 + var p models.Pipeline 219 + var t models.Trigger 220 var created string 221 222 err := rows.Scan( ··· 248 249 t.Id = p.TriggerId 250 p.Trigger = &t 251 + p.Statuses = make(map[string]models.WorkflowStatus) 252 253 k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 254 pipelines[k] = p ··· 287 defer rows.Close() 288 289 for rows.Next() { 290 + var ps models.PipelineStatus 291 var created string 292 293 err := rows.Scan( ··· 320 } 321 statuses, _ := pipeline.Statuses[ps.Workflow] 322 if !ok { 323 + pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{} 324 } 325 326 // append ··· 331 pipelines[key] = pipeline 332 } 333 334 + var all []models.Pipeline 335 for _, p := range pipelines { 336 for _, s := range p.Statuses { 337 + slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int { 338 if a.Created.After(b.Created) { 339 return 1 340 } ··· 354 } 355 356 // sort pipelines by date 357 + slices.SortFunc(all, func(a, b models.Pipeline) int { 358 if a.Created.After(b.Created) { 359 return -1 360 }
+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 + }
+7 -26
appview/db/pubkeys.go
··· 1 package db 2 3 import ( 4 - "encoding/json" 5 "time" 6 ) 7 ··· 29 return err 30 } 31 32 - type PublicKey struct { 33 - Did string `json:"did"` 34 - Key string `json:"key"` 35 - Name string `json:"name"` 36 - Rkey string `json:"rkey"` 37 - Created *time.Time 38 - } 39 - 40 - func (p PublicKey) MarshalJSON() ([]byte, error) { 41 - type Alias PublicKey 42 - return json.Marshal(&struct { 43 - Created string `json:"created"` 44 - *Alias 45 - }{ 46 - Created: p.Created.Format(time.RFC3339), 47 - Alias: (*Alias)(&p), 48 - }) 49 - } 50 - 51 - func GetAllPublicKeys(e Execer) ([]PublicKey, error) { 52 - var keys []PublicKey 53 54 rows, err := e.Query(`select key, name, did, rkey, created from public_keys`) 55 if err != nil { ··· 58 defer rows.Close() 59 60 for rows.Next() { 61 - var publicKey PublicKey 62 var createdAt string 63 if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil { 64 return nil, err ··· 75 return keys, nil 76 } 77 78 - func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) { 79 - var keys []PublicKey 80 81 rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did) 82 if err != nil { ··· 85 defer rows.Close() 86 87 for rows.Next() { 88 - var publicKey PublicKey 89 var createdAt string 90 if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil { 91 return nil, err
··· 1 package db 2 3 import ( 4 + "tangled.org/core/appview/models" 5 "time" 6 ) 7 ··· 29 return err 30 } 31 32 + func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) { 33 + var keys []models.PublicKey 34 35 rows, err := e.Query(`select key, name, did, rkey, created from public_keys`) 36 if err != nil { ··· 39 defer rows.Close() 40 41 for rows.Next() { 42 + var publicKey models.PublicKey 43 var createdAt string 44 if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil { 45 return nil, err ··· 56 return keys, nil 57 } 58 59 + func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) { 60 + var keys []models.PublicKey 61 62 rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did) 63 if err != nil { ··· 66 defer rows.Close() 67 68 for rows.Next() { 69 + var publicKey models.PublicKey 70 var createdAt string 71 if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil { 72 return nil, err
+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
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type Punch struct { 6 + Did string 7 + Date time.Time 8 + Count int 9 + } 10 + 11 + type Punchcard struct { 12 + Total int 13 + Punches []Punch 14 + }
+4 -43
appview/db/registration.go
··· 5 "fmt" 6 "strings" 7 "time" 8 - ) 9 - 10 - // Registration represents a knot registration. Knot would've been a better 11 - // name but we're stuck with this for historical reasons. 12 - type Registration struct { 13 - Id int64 14 - Domain string 15 - ByDid string 16 - Created *time.Time 17 - Registered *time.Time 18 - NeedsUpgrade bool 19 - } 20 - 21 - func (r *Registration) Status() Status { 22 - if r.NeedsUpgrade { 23 - return NeedsUpgrade 24 - } else if r.Registered != nil { 25 - return Registered 26 - } else { 27 - return Pending 28 - } 29 - } 30 - 31 - func (r *Registration) IsRegistered() bool { 32 - return r.Status() == Registered 33 - } 34 - 35 - func (r *Registration) IsNeedsUpgrade() bool { 36 - return r.Status() == NeedsUpgrade 37 - } 38 - 39 - func (r *Registration) IsPending() bool { 40 - return r.Status() == Pending 41 - } 42 - 43 - type Status uint32 44 45 - const ( 46 - Registered Status = iota 47 - Pending 48 - NeedsUpgrade 49 ) 50 51 - func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 52 - var registrations []Registration 53 54 var conditions []string 55 var args []any ··· 81 var createdAt string 82 var registeredAt sql.Null[string] 83 var needsUpgrade int 84 - var reg Registration 85 86 err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 if err != nil {
··· 5 "fmt" 6 "strings" 7 "time" 8 9 + "tangled.org/core/appview/models" 10 ) 11 12 + func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) { 13 + var registrations []models.Registration 14 15 var conditions []string 16 var args []any ··· 42 var createdAt string 43 var registeredAt sql.Null[string] 44 var needsUpgrade int 45 + var reg models.Registration 46 47 err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 48 if err != nil {
+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
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + }
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 349 defs[l.AtUri().String()] = &l 350 } 351 352 - return &models.LabelApplicationCtx{defs}, nil 353 }
··· 349 defs[l.AtUri().String()] = &l 350 } 351 352 + return &models.LabelApplicationCtx{Defs: defs}, nil 353 }
+1 -1
appview/reporesolver/resolver.go
··· 212 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 213 if u != nil { 214 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 215 - return repoinfo.RolesInRepo{r} 216 } else { 217 return repoinfo.RolesInRepo{} 218 }
··· 212 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 213 if u != nil { 214 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 215 + return repoinfo.RolesInRepo{Roles: r} 216 } else { 217 return repoinfo.RolesInRepo{} 218 }
+36 -6
appview/pages/templates/repo/settings/general.html
··· 46 47 {{ define "defaultLabelSettings" }} 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> 55 <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 {{ range .DefaultLabels }} 57 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
··· 46 47 {{ define "defaultLabelSettings" }} 48 <div class="flex flex-col gap-2"> 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> 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"> 86 {{ range .DefaultLabels }} 87 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
··· 82 {{ $event := index . 1 }} 83 {{ $follow := $event.Follow }} 84 {{ $profile := $event.Profile }} 85 - {{ $stat := $event.FollowStats }} 86 87 {{ $userHandle := resolve $follow.UserDid }} 88 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 92 {{ template "user/fragments/picHandleLink" $subjectHandle }} 93 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 94 </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> 127 {{ end }}
··· 82 {{ $event := index . 1 }} 83 {{ $follow := $event.Follow }} 84 {{ $profile := $event.Profile }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 87 88 {{ $userHandle := resolve $follow.UserDid }} 89 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 93 {{ template "user/fragments/picHandleLink" $subjectHandle }} 94 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 95 </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) }} 104 {{ end }}
+8 -1
appview/pages/templates/user/followers.html
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Followers }} 13 - {{ template "user/fragments/followCard" . }} 14 {{ else }} 15 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 {{ end }}
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Followers }} 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) }} 21 {{ else }} 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 23 {{ end }}
+8 -1
appview/pages/templates/user/following.html
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Following }} 13 - {{ template "user/fragments/followCard" . }} 14 {{ else }} 15 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 {{ end }}
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Following }} 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) }} 21 {{ else }} 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 23 {{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 {{ define "user/fragments/follow" }} 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 hx-swap="outerHTML" 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 }} 16 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 </button> 18 {{ end }}
··· 1 {{ define "user/fragments/follow" }} 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 + class="btn w-full flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 hx-swap="outerHTML" 14 > 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 }} 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 21 </button> 22 {{ end }}
+58 -3
appview/posthog/notifier.go appview/notify/posthog/notifier.go
··· 1 - package posthog_service 2 3 import ( 4 "context" ··· 98 } 99 } 100 101 func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 102 err := n.client.Enqueue(posthog.Capture{ 103 DistinctId: follow.UserDid, ··· 152 } 153 } 154 155 - func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) { 156 err := n.client.Enqueue(posthog.Capture{ 157 DistinctId: string.Did.String(), 158 - Event: "create_string", 159 Properties: posthog.Properties{"rkey": string.Rkey}, 160 }) 161 if err != nil { 162 log.Println("failed to enqueue posthog event:", err) 163 } 164 }
··· 1 + package posthog 2 3 import ( 4 "context" ··· 98 } 99 } 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 + 115 func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 116 err := n.client.Enqueue(posthog.Capture{ 117 DistinctId: follow.UserDid, ··· 166 } 167 } 168 169 + func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) { 170 err := n.client.Enqueue(posthog.Capture{ 171 DistinctId: string.Did.String(), 172 + Event: "new_string", 173 Properties: posthog.Properties{"rkey": string.Rkey}, 174 }) 175 if err != nil { 176 log.Println("failed to enqueue posthog event:", err) 177 } 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
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 ) 15 16 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 repoMap := make(map[syntax.ATURI]*models.Repo) 18 ··· 35 36 repoQuery := fmt.Sprintf( 37 `select 38 did, 39 name, 40 knot, ··· 63 var description, source, spindle sql.NullString 64 65 err := rows.Scan( 66 &repo.Did, 67 &repo.Name, 68 &repo.Knot, ··· 327 var repo models.Repo 328 var nullableDescription sql.NullString 329 330 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 331 332 var createdAt string 333 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 334 return nil, err 335 } 336 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 386 var repos []models.Repo 387 388 rows, err := e.Query( 389 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 390 from repos r 391 left join collaborators c on r.at_uri = c.repo_at 392 where (r.did = ? or c.subject_did = ?) ··· 406 var nullableDescription sql.NullString 407 var nullableSource sql.NullString 408 409 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 410 if err != nil { 411 return nil, err 412 } ··· 443 var nullableSource sql.NullString 444 445 row := e.QueryRow( 446 - `select did, name, knot, rkey, description, created, source 447 from repos 448 where did = ? and name = ? and source is not null and source != ''`, 449 did, name, 450 ) 451 452 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 453 if err != nil { 454 return nil, err 455 }
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 15 "tangled.org/core/appview/models" 16 ) 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 + 44 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 45 repoMap := make(map[syntax.ATURI]*models.Repo) 46 ··· 63 64 repoQuery := fmt.Sprintf( 65 `select 66 + id, 67 did, 68 name, 69 knot, ··· 92 var description, source, spindle sql.NullString 93 94 err := rows.Scan( 95 + &repo.Id, 96 &repo.Did, 97 &repo.Name, 98 &repo.Knot, ··· 357 var repo models.Repo 358 var nullableDescription sql.NullString 359 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 361 362 var createdAt string 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 364 return nil, err 365 } 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 416 var repos []models.Repo 417 418 rows, err := e.Query( 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 420 from repos r 421 left join collaborators c on r.at_uri = c.repo_at 422 where (r.did = ? or c.subject_did = ?) ··· 436 var nullableDescription sql.NullString 437 var nullableSource sql.NullString 438 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 440 if err != nil { 441 return nil, err 442 } ··· 473 var nullableSource sql.NullString 474 475 row := e.QueryRow( 476 + `select id, did, name, knot, rkey, description, created, source 477 from repos 478 where did = ? and name = ? and source is not null and source != ''`, 479 did, name, 480 ) 481 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 483 if err != nil { 484 return nil, err 485 }
+12
appview/notify/merged_notifier.go
··· 72 } 73 } 74 75 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 76 for _, notifier := range m.notifiers { 77 notifier.UpdateProfile(ctx, profile)
··· 72 } 73 } 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 + 87 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 88 for _, notifier := range m.notifiers { 89 notifier.UpdateProfile(ctx, profile)
+4 -11
appview/pages/templates/errors/500.html
··· 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 <div class="mb-6"> 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" }} 9 </div> 10 </div> 11 ··· 14 500 &mdash; internal server error 15 </h1> 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> 26 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 <button onclick="location.reload()" class="btn-create gap-2"> 28 {{ i "refresh-cw" "w-4 h-4" }} 29 try again 30 </button> 31 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 33 back to home 34 </a> 35 </div>
··· 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 <div class="mb-6"> 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 "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 </div> 10 </div> 11 ··· 14 500 &mdash; internal server error 15 </h1> 16 <p class="text-gray-600 dark:text-gray-300"> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 <button onclick="location.reload()" class="btn-create gap-2"> 21 {{ i "refresh-cw" "w-4 h-4" }} 22 try again 23 </button> 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 25 + {{ i "arrow-left" "w-4 h-4" }} 26 back to home 27 </a> 28 </div>
+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
··· 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 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> 8 {{ else }} 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 {{ end }}
··· 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 5 {{ if eq .Action "new" }} 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 {{ else }} 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 {{ end }}
+4 -1
appview/validator/validator.go
··· 4 "tangled.org/core/appview/db" 5 "tangled.org/core/appview/pages/markup" 6 "tangled.org/core/idresolver" 7 ) 8 9 type Validator struct { 10 db *db.DB 11 sanitizer markup.Sanitizer 12 resolver *idresolver.Resolver 13 } 14 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 16 return &Validator{ 17 db: db, 18 sanitizer: markup.NewSanitizer(), 19 resolver: res, 20 } 21 }
··· 4 "tangled.org/core/appview/db" 5 "tangled.org/core/appview/pages/markup" 6 "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 8 ) 9 10 type Validator struct { 11 db *db.DB 12 sanitizer markup.Sanitizer 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 15 } 16 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 18 return &Validator{ 19 db: db, 20 sanitizer: markup.NewSanitizer(), 21 resolver: res, 22 + enforcer: enforcer, 23 } 24 }
+160 -144
appview/pages/templates/repo/new.html
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-2 mb-4"> 5 - <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 6 - <p class="text-gray-600 dark:text-gray-400 mb-1"> 7 - Repositories contain a project's files and version history. 8 - </p> 9 </div> 10 11 - <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 12 <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 13 14 - <div class="p-6"> 15 - <!-- Step 1: General --> 16 - <div class="flex gap-4"> 17 - <div class="flex flex-col items-center w-10 flex-shrink-0"> 18 - <div class="w-6 h-6 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 19 - 1 20 - </div> 21 - <div class="w-px bg-gray-300 dark:bg-gray-600 h-80 mt-1"></div> 22 - </div> 23 - <div class="flex-1"> 24 - <div class="mb-6"> 25 - <div class="text-lg font-semibold dark:text-white mb-1">General</div> 26 - <div class="text-sm text-gray-500 dark:text-gray-400">Basic repository information.</div> 27 - </div> 28 - 29 - <div class="space-y-2"> 30 - <!-- Repository Name with Owner --> 31 - <div> 32 - <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 33 - Repository name 34 - </label> 35 - <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0"> 36 - {{ if .LoggedInUser.Handle }} 37 - <div class="flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 38 - {{ template "user/fragments/picHandle" .LoggedInUser.Handle }} 39 - <span class="md:hidden pr-2 py-2 text-gray-500 dark:text-gray-400">/</span> 40 - </div> 41 - <span class="hidden md:inline pr-2 py-2 text-gray-500 dark:text-gray-400 border-t border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">/</span> 42 - {{ end }} 43 - <input 44 - type="text" 45 - id="name" 46 - name="name" 47 - required 48 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 49 - placeholder="repository-name" 50 - /> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 53 - Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 54 - </p> 55 - </div> 56 - 57 - <!-- Description --> 58 - <div> 59 - <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 60 - Description 61 - </label> 62 - <input 63 - type="text" 64 - id="description" 65 - name="description" 66 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 67 - placeholder="A brief description of your project..." 68 - /> 69 - <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 70 - Optional. A short description to help others understand what your project does. 71 - </p> 72 - </div> 73 - </div> 74 - </div> 75 </div> 76 77 - <!-- Step 2: Configuration --> 78 - <div class="flex gap-4"> 79 - <div class="flex flex-col items-center w-10 flex-shrink-0"> 80 - <div class="w-6 h-6 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 81 - 2 82 - </div> 83 - </div> 84 - <div class="flex-1"> 85 - <div class="mb-6"> 86 - <div class="text-lg font-semibold dark:text-white mb-1">Configuration</div> 87 - <div class="text-sm text-gray-500 dark:text-gray-400">Repository settings and hosting.</div> 88 - </div> 89 - 90 - <div class="space-y-2"> 91 - <!-- Default Branch --> 92 - <div> 93 - <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 94 - Default branch 95 - </label> 96 - <input 97 - type="text" 98 - id="branch" 99 - name="branch" 100 - value="main" 101 - required 102 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 103 - /> 104 - <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 105 - The primary branch where development happens. Common choices are "main" or "master". 106 - </p> 107 - </div> 108 - 109 - <!-- Knot Selection --> 110 - <div> 111 - <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 112 - Select a knot 113 - </label> 114 - <div class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 115 - {{ range .Knots }} 116 - <div class="flex items-center"> 117 - <input 118 - type="radio" 119 - name="domain" 120 - value="{{ . }}" 121 - class="mr-2" 122 - id="domain-{{ . }}" 123 - required 124 - /> 125 - <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 126 - </div> 127 - {{ else }} 128 - <p class="dark:text-white">no knots available.</p> 129 - {{ end }} 130 - </div> 131 - <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 132 - A knot hosts repository data and handles Git operations. 133 - You can also <a href="/knots" class="underline">register your own knot</a>. 134 - </p> 135 - </div> 136 - </div> 137 - </div> 138 </div> 139 </div> 140 141 - <!-- Create Repository Button --> 142 - <div class="p-6 border-t border-gray-200 dark:border-gray-700"> 143 - <div class="flex items-center justify-between"> 144 - <div class="text-sm text-gray-500 dark:text-gray-400"> 145 - All repositories are publicly accessible. 146 - </div> 147 - <button type="submit" class="btn-create flex items-center gap-2"> 148 - {{ i "book-plus" "w-4 h-4" }} 149 - create repo 150 - <span id="spinner" class="group"> 151 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 152 - </span> 153 - </button> 154 </div> 155 - <div id="repo" class="error mt-2"></div> 156 </div> 157 158 - </form> 159 - </div> 160 {{ end }}
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 4 + <div class="grid grid-cols-12"> 5 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 6 + <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Repositories contain a project's files and version history. All 9 + repositories are publicly accessible. 10 + </p> 11 + </div> 12 + {{ template "newRepoPanel" . }} 13 </div> 14 + {{ end }} 15 + 16 + {{ define "newRepoPanel" }} 17 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 18 + {{ template "newRepoForm" . }} 19 + </div> 20 + {{ end }} 21 22 + {{ define "newRepoForm" }} 23 <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 24 + {{ template "step-1" . }} 25 + {{ template "step-2" . }} 26 27 + <div class="mt-8 flex justify-end"> 28 + <button type="submit" class="btn-create flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + create repo 31 + <span id="spinner" class="group"> 32 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </span> 34 + </button> 35 + </div> 36 + <div id="repo" class="error mt-2"></div> 37 + 38 + </form> 39 + {{ end }} 40 + 41 + {{ define "step-1" }} 42 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 43 + <div class="absolute -left-3 -top-0"> 44 + {{ template "numberCircle" 1 }} 45 + </div> 46 + 47 + <!-- Content column --> 48 + <div class="flex-1 pb-12"> 49 + <h2 class="text-lg font-semibold dark:text-white">General</h2> 50 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div> 51 + 52 + <div class="space-y-2"> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 55 </div> 56 + </div> 57 + </div> 58 + {{ end }} 59 60 + {{ define "step-2" }} 61 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 62 + <div class="absolute -left-3 -top-0"> 63 + {{ template "numberCircle" 2 }} 64 + </div> 65 + 66 + <div class="flex-1"> 67 + <h2 class="text-lg font-semibold dark:text-white">Configuration</h2> 68 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div> 69 + 70 + <div class="space-y-2"> 71 + {{ template "defaultBranch" . }} 72 + {{ template "knot" . }} 73 </div> 74 </div> 75 + </div> 76 + {{ end }} 77 78 + {{ define "name" }} 79 + <!-- Repository Name with Owner --> 80 + <div> 81 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 82 + Repository name 83 + </label> 84 + <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full"> 85 + <div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 86 + {{ template "user/fragments/picHandle" .LoggedInUser.Did }} 87 </div> 88 + <input 89 + type="text" 90 + id="name" 91 + name="name" 92 + required 93 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 94 + placeholder="repository-name" 95 + /> 96 </div> 97 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 99 + </p> 100 + </div> 101 + {{ end }} 102 103 + {{ define "description" }} 104 + <!-- Description --> 105 + <div> 106 + <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 107 + Description 108 + </label> 109 + <input 110 + type="text" 111 + id="description" 112 + name="description" 113 + class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 + placeholder="A brief description of your project..." 115 + /> 116 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 + Optional. A short description to help others understand what your project does. 118 + </p> 119 + </div> 120 + {{ end }} 121 + 122 + {{ define "defaultBranch" }} 123 + <!-- Default Branch --> 124 + <div> 125 + <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 126 + Default branch 127 + </label> 128 + <input 129 + type="text" 130 + id="branch" 131 + name="branch" 132 + value="main" 133 + required 134 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 135 + /> 136 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 137 + The primary branch where development happens. Common choices are "main" or "master". 138 + </p> 139 + </div> 140 + {{ end }} 141 + 142 + {{ define "knot" }} 143 + <!-- Knot Selection --> 144 + <div> 145 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 146 + Select a knot 147 + </label> 148 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 149 + {{ range .Knots }} 150 + <div class="flex items-center"> 151 + <input 152 + type="radio" 153 + name="domain" 154 + value="{{ . }}" 155 + class="mr-2" 156 + id="domain-{{ . }}" 157 + required 158 + /> 159 + <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 + </div> 161 + {{ else }} 162 + <p class="dark:text-white">no knots available.</p> 163 + {{ end }} 164 + </div> 165 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 + A knot hosts repository data and handles Git operations. 167 + You can also <a href="/knots" class="underline">register your own knot</a>. 168 + </p> 169 + </div> 170 + {{ end }} 171 + 172 + {{ define "numberCircle" }} 173 + <div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 174 + {{.}} 175 + </div> 176 {{ end }}
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
+6
appview/pages/templates/repo/tree.html
··· 88 </div> 89 </main> 90 {{end}}
··· 88 </div> 89 </main> 90 {{end}} 91 + 92 + {{ define "repoAfter" }} 93 + {{- if or .HTMLReadme .Readme -}} 94 + {{ template "repo/fragments/readme" . }} 95 + {{- end -}} 96 + {{ end }}
+7 -5
types/repo.go
··· 41 } 42 43 type RepoTreeResponse struct { 44 - Ref string `json:"ref,omitempty"` 45 - Parent string `json:"parent,omitempty"` 46 - Description string `json:"description,omitempty"` 47 - DotDot string `json:"dotdot,omitempty"` 48 - Files []NiceTree `json:"files,omitempty"` 49 } 50 51 type TagReference struct {
··· 41 } 42 43 type RepoTreeResponse struct { 44 + Ref string `json:"ref,omitempty"` 45 + Parent string `json:"parent,omitempty"` 46 + Description string `json:"description,omitempty"` 47 + DotDot string `json:"dotdot,omitempty"` 48 + Files []NiceTree `json:"files,omitempty"` 49 + ReadmeFileName string `json:"readme_filename,omitempty"` 50 + Readme string `json:"readme_contents,omitempty"` 51 } 52 53 type TagReference struct {
+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 {{ define "user/fragments/followCard" }} 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"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
··· 1 {{ define "user/fragments/followCard" }} 2 {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
+4 -2
appview/config/config.go
··· 72 } 73 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 77 } 78 79 func (cfg RedisConfig) ToURL() string {
··· 72 } 73 74 type Cloudflare struct { 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 79 } 80 81 func (cfg RedisConfig) ToURL() string {
+13 -9
appview/db/email.go
··· 71 return did, nil 72 } 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 81 verifiedFilter = 1 82 } 83 84 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 88 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 92 } 93 94 query := ` ··· 105 } 106 defer rows.Close() 107 108 - assoc := make(map[string]string) 109 - 110 for rows.Next() { 111 var email, did string 112 if err := rows.Scan(&email, &did); err != nil {
··· 71 return did, nil 72 } 73 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 81 verifiedFilter = 1 82 } 83 84 + assoc := make(map[string]string) 85 + 86 // Create placeholders for the IN clause 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 89 90 args[0] = verifiedFilter 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) 98 } 99 100 query := ` ··· 111 } 112 defer rows.Close() 113 114 for rows.Next() { 115 var email, did string 116 if err := rows.Scan(&email, &did); err != nil {
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 </div> 112 </div> 113 {{ if .StackId }}
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 + 112 + {{ $state := .Labels }} 113 + {{ range $k, $d := $.LabelDefs }} 114 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 115 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 116 + {{ end }} 117 + {{ end }} 118 </div> 119 </div> 120 {{ if .StackId }}
+8 -48
appview/notify/db/db.go
··· 30 31 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 var err error 33 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt))) 34 if err != nil { 35 log.Printf("NewStar: failed to get repos: %v", err) 36 return 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 44 // don't notify yourself 45 if repo.Did == star.StarredByDid { ··· 76 } 77 78 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))) 80 if err != nil { 81 log.Printf("NewIssue: failed to get repos: %v", err) 82 return 83 } 84 - if len(repos) == 0 { 85 - log.Printf("NewIssue: no repo found for %s", issue.RepoAt) 86 - return 87 - } 88 - repo := repos[0] 89 90 if repo.Did == issue.Did { 91 return ··· 129 } 130 issue := issues[0] 131 132 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 133 if err != nil { 134 log.Printf("NewIssueComment: failed to get repos: %v", err) 135 return 136 } 137 - if len(repos) == 0 { 138 - log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt) 139 - return 140 - } 141 - repo := repos[0] 142 143 recipients := make(map[string]bool) 144 ··· 211 } 212 213 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))) 215 if err != nil { 216 log.Printf("NewPull: failed to get repos: %v", err) 217 return 218 } 219 - if len(repos) == 0 { 220 - log.Printf("NewPull: no repo found for %s", pull.RepoAt) 221 - return 222 - } 223 - repo := repos[0] 224 225 if repo.Did == pull.OwnerDid { 226 return ··· 266 } 267 pull := pulls[0] 268 269 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt)) 270 if err != nil { 271 log.Printf("NewPullComment: failed to get repos: %v", err) 272 return 273 } 274 - if len(repos) == 0 { 275 - log.Printf("NewPullComment: no repo found for %s", comment.RepoAt) 276 - return 277 - } 278 - repo := repos[0] 279 280 recipients := make(map[string]bool) 281 ··· 335 336 func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 337 // Get repo details 338 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 339 if err != nil { 340 log.Printf("NewIssueClosed: failed to get repos: %v", err) 341 return 342 } 343 - if len(repos) == 0 { 344 - log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt) 345 - return 346 - } 347 - repo := repos[0] 348 349 // Don't notify yourself 350 if repo.Did == issue.Did { ··· 380 381 func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 382 // Get repo details 383 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 384 if err != nil { 385 log.Printf("NewPullMerged: failed to get repos: %v", err) 386 return 387 } 388 - if len(repos) == 0 { 389 - log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt) 390 - return 391 - } 392 - repo := repos[0] 393 394 // Don't notify yourself 395 if repo.Did == pull.OwnerDid { ··· 425 426 func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 427 // Get repo details 428 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 429 if err != nil { 430 log.Printf("NewPullClosed: failed to get repos: %v", err) 431 return 432 } 433 - if len(repos) == 0 { 434 - log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt) 435 - return 436 - } 437 - repo := repos[0] 438 439 // Don't notify yourself 440 if repo.Did == pull.OwnerDid {
··· 30 31 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 var err error 33 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 34 if err != nil { 35 log.Printf("NewStar: failed to get repos: %v", err) 36 return 37 } 38 39 // don't notify yourself 40 if repo.Did == star.StarredByDid { ··· 71 } 72 73 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 if err != nil { 76 log.Printf("NewIssue: failed to get repos: %v", err) 77 return 78 } 79 80 if repo.Did == issue.Did { 81 return ··· 119 } 120 issue := issues[0] 121 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 if err != nil { 124 log.Printf("NewIssueComment: failed to get repos: %v", err) 125 return 126 } 127 128 recipients := make(map[string]bool) 129 ··· 196 } 197 198 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 199 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 200 if err != nil { 201 log.Printf("NewPull: failed to get repos: %v", err) 202 return 203 } 204 205 if repo.Did == pull.OwnerDid { 206 return ··· 246 } 247 pull := pulls[0] 248 249 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 if err != nil { 251 log.Printf("NewPullComment: failed to get repos: %v", err) 252 return 253 } 254 255 recipients := make(map[string]bool) 256 ··· 310 311 func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 // Get repo details 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 314 if err != nil { 315 log.Printf("NewIssueClosed: failed to get repos: %v", err) 316 return 317 } 318 319 // Don't notify yourself 320 if repo.Did == issue.Did { ··· 350 351 func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 352 // Get repo details 353 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 354 if err != nil { 355 log.Printf("NewPullMerged: failed to get repos: %v", err) 356 return 357 } 358 359 // Don't notify yourself 360 if repo.Did == pull.OwnerDid { ··· 390 391 func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 // Get repo details 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 if err != nil { 395 log.Printf("NewPullClosed: failed to get repos: %v", err) 396 return 397 } 398 399 // Don't notify yourself 400 if repo.Did == pull.OwnerDid {
+18 -13
appview/db/notifications.go
··· 3 import ( 4 "context" 5 "database/sql" 6 "fmt" 7 "time" 8 9 "tangled.org/core/appview/models" ··· 248 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 249 } 250 251 - func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) { 252 - recipientFilter := FilterEq("recipient_did", userDID) 253 - readFilter := FilterEq("read", 0) 254 255 - query := fmt.Sprintf(` 256 - SELECT COUNT(*) 257 - FROM notifications 258 - WHERE %s AND %s 259 - `, recipientFilter.Condition(), readFilter.Condition()) 260 261 - args := append(recipientFilter.Arg(), readFilter.Arg()...) 262 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) 267 } 268 269 return count, nil
··· 3 import ( 4 "context" 5 "database/sql" 6 + "errors" 7 "fmt" 8 + "strings" 9 "time" 10 11 "tangled.org/core/appview/models" ··· 250 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 251 } 252 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 + } 260 261 + whereClause := "" 262 + if conditions != nil { 263 + whereClause = " where " + strings.Join(conditions, " and ") 264 + } 265 266 + query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 267 + var count int64 268 + err := e.QueryRow(query, args...).Scan(&count) 269 270 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 271 + return 0, err 272 } 273 274 return count, nil
+48 -15
appview/pages/templates/notifications/list.html
··· 11 </div> 12 </div> 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> 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> 29 </div> 30 </div> 31 - {{end}} 32 {{ end }}
··· 11 </div> 12 </div> 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> 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> 29 </div> 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> 65 {{ end }}
+1 -1
appview/pagination/page.go
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 - Limit: 10, 12 } 13 } 14
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 + Limit: 30, 12 } 13 } 14
+27 -208
appview/pages/templates/notifications/fragments/item.html
··· 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 - > 9 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 </div> 15 - 16 - </div> 17 {{end}} 18 19 {{ define "notificationIcon" }} ··· 64 {{ end }} 65 {{ end }} 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> 256 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}}
··· 1 {{define "notifications/fragments/item"}} 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> 14 15 </div> 16 + </a> 17 {{end}} 18 19 {{ define "notificationIcon" }} ··· 64 {{ end }} 65 {{ end }} 66 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 }} 79 80 + {{ $url }} 81 + {{ end }}
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 sqlite-lib, 5 src, 6 }: let 7 - version = "1.9.0-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot";
··· 4 sqlite-lib, 5 src, 6 }: let 7 + version = "1.9.1-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot";
+2 -2
appview/db/pulls.go
··· 246 // collect pull source for all pulls that need it 247 var sourceAts []syntax.ATURI 248 for _, p := range pulls { 249 - if p.PullSource.RepoAt != nil { 250 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 } 252 } ··· 259 sourceRepoMap[r.RepoAt()] = &r 260 } 261 for _, p := range pulls { 262 - if p.PullSource.RepoAt != nil { 263 if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 p.PullSource.Repo = sourceRepo 265 }
··· 246 // collect pull source for all pulls that need it 247 var sourceAts []syntax.ATURI 248 for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 } 252 } ··· 259 sourceRepoMap[r.RepoAt()] = &r 260 } 261 for _, p := range pulls { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 p.PullSource.Repo = sourceRepo 265 }
+1 -1
docs/spindle/pipeline.md
··· 21 - `manual`: The workflow can be triggered manually. 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ```yaml 27 when:
··· 21 - `manual`: The workflow can be triggered manually. 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 26 ```yaml 27 when:
+1 -1
knotserver/config/config.go
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
+1
appview/pages/templates/user/signup.html
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>sign up &middot; tangled</title> 13
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>sign up &middot; tangled</title> 14
+5
appview/models/repo.go
··· 86 RepoAt syntax.ATURI 87 LabelAt syntax.ATURI 88 }
··· 86 RepoAt syntax.ATURI 87 LabelAt syntax.ATURI 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+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
···
··· 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
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 {{ end }}
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 17 {{ template "timeline/fragments/trending" . }} 18 {{ template "timeline/fragments/timeline" . }} 19 {{ end }}
+2 -5
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 6 <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 <p> 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. 14 </p> 15 <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 16 Browse issues {{ i "arrow-right" "size-4" }}
··· 6 <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 <p> 8 Make your first contribution to an open-source project this October. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 11 </p> 12 <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 Browse issues {{ i "arrow-right" "size-4" }}
+3 -4
appview/pages/templates/goodfirstissues/index.html
··· 37 {{ else }} 38 {{ range .RepoGroups }} 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"> 41 <div class="font-medium dark:text-white flex items-center justify-between"> 42 <div class="flex items-center min-w-0 flex-1 mr-2"> 43 {{ if .Repo.Source }} ··· 103 <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 <span> 105 <div class="inline-flex items-center gap-1"> 106 - {{ i "message-square" "w-3 h-3 md:hidden" }} 107 {{ len .Comments }} 108 - <span class="hidden md:inline">comment{{ if ne (len .Comments) 1 }}s{{ end }}</span> 109 </div> 110 </span> 111 <span class="before:content-['·'] before:select-none"></span> 112 <span class="text-sm"> 113 - {{ template "repo/fragments/time" .Created }} 114 </span> 115 <div class="hidden md:inline-flex md:gap-1"> 116 {{ $labelState := .Labels }}
··· 37 {{ else }} 38 {{ range .RepoGroups }} 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 flex-wrap"> 41 <div class="font-medium dark:text-white flex items-center justify-between"> 42 <div class="flex items-center min-w-0 flex-1 mr-2"> 43 {{ if .Repo.Source }} ··· 103 <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 <span> 105 <div class="inline-flex items-center gap-1"> 106 + {{ i "message-square" "w-3 h-3" }} 107 {{ len .Comments }} 108 </div> 109 </span> 110 <span class="before:content-['·'] before:select-none"></span> 111 <span class="text-sm"> 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 113 </span> 114 <div class="hidden md:inline-flex md:gap-1"> 115 {{ $labelState := .Labels }}
+13 -1
appview/state/gfi.go
··· 47 repoUris = append(repoUris, rl.RepoAt.String()) 48 } 49 50 - allIssues, err := db.GetIssues( 51 s.db, 52 db.FilterIn("repo_at", repoUris), 53 db.FilterEq("open", 1), 54 ) ··· 83 } 84 85 sort.Slice(sortedGroups, func(i, j int) bool { 86 return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 87 }) 88
··· 47 repoUris = append(repoUris, rl.RepoAt.String()) 48 } 49 50 + allIssues, err := db.GetIssuesPaginated( 51 s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 db.FilterIn("repo_at", repoUris), 56 db.FilterEq("open", 1), 57 ) ··· 86 } 87 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 98 return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 }) 100
+2 -1
appview/oauth/consts.go
··· 1 package oauth 2 3 const ( 4 - SessionName = "appview-session" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 SessionPds = "pds" 8 SessionAccessJwt = "accessJwt" 9 SessionRefreshJwt = "refreshJwt"
··· 1 package oauth 2 3 const ( 4 + SessionName = "appview-session-v2" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 + SessionId = "id" 8 SessionPds = "pds" 9 SessionAccessJwt = "accessJwt" 10 SessionRefreshJwt = "refreshJwt"
+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
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 + {{ resolve .LoggedInUser.Did }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+11 -10
appview/repo/artifact.go
··· 10 "net/url" 11 "time" 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 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/db" 22 "tangled.org/core/appview/models" ··· 25 "tangled.org/core/appview/xrpcclient" 26 "tangled.org/core/tid" 27 "tangled.org/core/types" 28 ) 29 30 // TODO: proper statuses here on early exit ··· 60 return 61 } 62 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 if err != nil { 65 log.Println("failed to upload blob", err) 66 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 rkey := tid.TID() 73 createdAt := time.Now() 74 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 Collection: tangled.RepoArtifactNSID, 77 Repo: user.Did, 78 Rkey: rkey, ··· 249 return 250 } 251 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 Collection: tangled.RepoArtifactNSID, 254 Repo: user.Did, 255 Rkey: artifact.Rkey,
··· 10 "net/url" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" ··· 18 "tangled.org/core/appview/xrpcclient" 19 "tangled.org/core/tid" 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" 29 ) 30 31 // TODO: proper statuses here on early exit ··· 61 return 62 } 63 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 65 if err != nil { 66 log.Println("failed to upload blob", err) 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 73 rkey := tid.TID() 74 createdAt := time.Now() 75 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 Collection: tangled.RepoArtifactNSID, 78 Repo: user.Did, 79 Rkey: rkey, ··· 250 return 251 } 252 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 254 Collection: tangled.RepoArtifactNSID, 255 Repo: user.Did, 256 Rkey: artifact.Rkey,
+5 -5
appview/spindles/spindles.go
··· 189 return 190 } 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
··· 189 return 190 } 191 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
+2 -2
appview/state/follow.go
··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 94 Rkey: follow.Rkey,
··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 94 Rkey: follow.Rkey,
+2 -2
appview/state/star.go
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
··· 1 {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 </div> 139 </form> 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 143 </div> 144 {{ end }} 145 {{ end }}
··· 138 </div> 139 </form> 140 {{ else }} 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 148 </div> 149 {{ end }} 150 {{ end }}
+4 -2
appview/pages/templates/repo/issues/issue.html
··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 {{ 114 template "repo/fragments/reaction" 115 (dict 116 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 }} 121 {{ end }} 122 </div>
··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 114 {{ 115 template "repo/fragments/reaction" 116 (dict 117 "Kind" $kind 118 + "Count" $reactionData.Count 119 "IsReacted" (index $.UserReacted $kind) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 122 }} 123 {{ end }} 124 </div>
+6 -1
appview/pages/markup/markdown.go
··· 5 "bytes" 6 "fmt" 7 "io" 8 "net/url" 9 "path" 10 "strings" ··· 20 "github.com/yuin/goldmark/renderer/html" 21 "github.com/yuin/goldmark/text" 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 "tangled.org/core/api/tangled" ··· 45 IsDev bool 46 RendererType RendererType 47 Sanitizer Sanitizer 48 } 49 50 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 62 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 ), 64 treeblood.MathML(), 65 ), 66 goldmark.WithParserOptions( 67 parser.WithAutoHeadingID(), ··· 140 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 switch node.Type { 142 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 144 for i, attr := range node.Attr { 145 if attr.Key != "src" { 146 continue
··· 5 "bytes" 6 "fmt" 7 "io" 8 + "io/fs" 9 "net/url" 10 "path" 11 "strings" ··· 21 "github.com/yuin/goldmark/renderer/html" 22 "github.com/yuin/goldmark/text" 23 "github.com/yuin/goldmark/util" 24 + callout "gitlab.com/staticnoise/goldmark-callout" 25 htmlparse "golang.org/x/net/html" 26 27 "tangled.org/core/api/tangled" ··· 47 IsDev bool 48 RendererType RendererType 49 Sanitizer Sanitizer 50 + Files fs.FS 51 } 52 53 func (rctx *RenderContext) RenderMarkdown(source string) string { ··· 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 66 ), 67 treeblood.MathML(), 68 + callout.CalloutExtention, 69 ), 70 goldmark.WithParserOptions( 71 parser.WithAutoHeadingID(), ··· 144 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 145 switch node.Type { 146 case htmlparse.ElementNode: 147 + switch node.Data { 148 + case "img", "source": 149 for i, attr := range node.Attr { 150 if attr.Key != "src" { 151 continue
+71 -12
input.css
··· 134 } 135 136 .prose hr { 137 - @apply my-2; 138 } 139 140 .prose li:has(input) { 141 - @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 - @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 - @apply no-underline; 162 } 163 164 .prose li { 165 - @apply my-0 py-0; 166 } 167 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 170 } 171 172 .prose img { ··· 176 } 177 178 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 180 } 181 182 .prose input[type="checkbox"] { 183 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 } 185 } 186 @layer utilities { 187 .error { ··· 228 } 229 /* LineHighlight */ 230 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 232 } 233 234 /* LineNumbersTable */
··· 134 } 135 136 .prose hr { 137 + @apply my-2; 138 } 139 140 .prose li:has(input) { 141 + @apply list-none; 142 } 143 144 .prose ul:has(input) { 145 + @apply pl-2; 146 } 147 148 .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 } 151 152 .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 } 155 156 .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 } 159 160 .prose a.footnote-backref { 161 + @apply no-underline; 162 } 163 164 .prose li { 165 + @apply my-0 py-0; 166 } 167 168 + .prose ul, 169 + .prose ol { 170 + @apply my-1 py-0; 171 } 172 173 .prose img { ··· 177 } 178 179 .prose input { 180 + @apply inline-block my-0 mb-1 mx-1; 181 } 182 183 .prose input[type="checkbox"] { 184 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 185 } 186 + 187 + /* Base callout */ 188 + details[data-callout] { 189 + @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 190 + } 191 + 192 + details[data-callout] > summary { 193 + @apply font-bold cursor-pointer mb-1; 194 + } 195 + 196 + details[data-callout] > .callout-content { 197 + @apply text-sm leading-snug; 198 + } 199 + 200 + /* Note (blue) */ 201 + details[data-callout="note" i] { 202 + @apply border-blue-400 dark:border-blue-500; 203 + } 204 + details[data-callout="note" i] > summary { 205 + @apply text-blue-700 dark:text-blue-400; 206 + } 207 + 208 + /* Important (purple) */ 209 + details[data-callout="important" i] { 210 + @apply border-purple-400 dark:border-purple-500; 211 + } 212 + details[data-callout="important" i] > summary { 213 + @apply text-purple-700 dark:text-purple-400; 214 + } 215 + 216 + /* Warning (yellow) */ 217 + details[data-callout="warning" i] { 218 + @apply border-yellow-400 dark:border-yellow-500; 219 + } 220 + details[data-callout="warning" i] > summary { 221 + @apply text-yellow-700 dark:text-yellow-400; 222 + } 223 + 224 + /* Caution (red) */ 225 + details[data-callout="caution" i] { 226 + @apply border-red-400 dark:border-red-500; 227 + } 228 + details[data-callout="caution" i] > summary { 229 + @apply text-red-700 dark:text-red-400; 230 + } 231 + 232 + /* Tip (green) */ 233 + details[data-callout="tip" i] { 234 + @apply border-green-400 dark:border-green-500; 235 + } 236 + details[data-callout="tip" i] > summary { 237 + @apply text-green-700 dark:text-green-400; 238 + } 239 + 240 + /* Optional: hide the disclosure arrow like GitHub */ 241 + details[data-callout] > summary::-webkit-details-marker { 242 + display: none; 243 + } 244 } 245 @layer utilities { 246 .error { ··· 287 } 288 /* LineHighlight */ 289 .chroma .hl { 290 + @apply bg-amber-400/30 dark:bg-amber-500/20; 291 } 292 293 /* LineNumbersTable */
+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
··· 71 return &g, nil 72 } 73 74 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 75 commits := []*object.Commit{} 76
··· 71 return &g, nil 72 } 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 + 85 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 86 commits := []*object.Commit{} 87
+144 -31
knotserver/git/merge.go
··· 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 "os" 8 "os/exec" 9 "regexp" ··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 ) 16 17 type MergeCheckCache struct { ··· 162 return nil 163 } 164 165 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 var stderr bytes.Buffer 167 var cmd *exec.Cmd 168 169 // configure default git user before merge 170 - exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 - exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 173 174 // if patch is a format-patch, apply using 'git am' 175 if opts.FormatPatch { 176 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 - } else { 178 - // else, apply using 'git apply' and commit it manually 179 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 - applyCmd.Stderr = &stderr 181 - if err := applyCmd.Run(); err != nil { 182 - return fmt.Errorf("patch application failed: %s", stderr.String()) 183 - } 184 185 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 - if err := stageCmd.Run(); err != nil { 187 - return fmt.Errorf("failed to stage changes: %w", err) 188 - } 189 190 - commitArgs := []string{"-C", tmpDir, "commit"} 191 192 - // Set author if provided 193 - authorName := opts.AuthorName 194 - authorEmail := opts.AuthorEmail 195 196 - if authorName != "" && authorEmail != "" { 197 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 - } 199 - // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 200 201 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 202 203 - if opts.CommitBody != "" { 204 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 - } 206 207 - cmd = exec.Command("git", commitArgs...) 208 } 209 210 cmd.Stderr = &stderr 211 212 if err := cmd.Run(); err != nil { ··· 216 return nil 217 } 218 219 - func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 return val 222 } ··· 263 } 264 defer os.RemoveAll(tmpDir) 265 266 - if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 267 return err 268 } 269
··· 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 + "log" 8 "os" 9 "os/exec" 10 "regexp" ··· 13 "github.com/dgraph-io/ristretto" 14 "github.com/go-git/go-git/v5" 15 "github.com/go-git/go-git/v5/plumbing" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 18 ) 19 20 type MergeCheckCache struct { ··· 165 return nil 166 } 167 168 + func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 169 var stderr bytes.Buffer 170 var cmd *exec.Cmd 171 172 // configure default git user before merge 173 + exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 + exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 + exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 176 177 // if patch is a format-patch, apply using 'git am' 178 if opts.FormatPatch { 179 + return g.applyMailbox(patchData) 180 + } 181 182 + // else, apply using 'git apply' and commit it manually 183 + applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 184 + applyCmd.Stderr = &stderr 185 + if err := applyCmd.Run(); err != nil { 186 + return fmt.Errorf("patch application failed: %s", stderr.String()) 187 + } 188 189 + stageCmd := exec.Command("git", "-C", g.path, "add", ".") 190 + if err := stageCmd.Run(); err != nil { 191 + return fmt.Errorf("failed to stage changes: %w", err) 192 + } 193 194 + commitArgs := []string{"-C", g.path, "commit"} 195 196 + // Set author if provided 197 + authorName := opts.AuthorName 198 + authorEmail := opts.AuthorEmail 199 200 + if authorName != "" && authorEmail != "" { 201 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 202 + } 203 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 204 205 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 207 + if opts.CommitBody != "" { 208 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 209 } 210 211 + cmd = exec.Command("git", commitArgs...) 212 + 213 cmd.Stderr = &stderr 214 215 if err := cmd.Run(); err != nil { ··· 219 return nil 220 } 221 222 + func (g *GitRepo) applyMailbox(patchData string) error { 223 + fps, err := patchutil.ExtractPatches(patchData) 224 + if err != nil { 225 + return fmt.Errorf("failed to extract patches: %w", err) 226 + } 227 + 228 + // apply each patch one by one 229 + // update the newly created commit object to add the change-id header 230 + total := len(fps) 231 + for i, p := range fps { 232 + newCommit, err := g.applySingleMailbox(p) 233 + if err != nil { 234 + return err 235 + } 236 + 237 + log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 238 + } 239 + 240 + return nil 241 + } 242 + 243 + func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 + tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 245 + if err != nil { 246 + return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 + } 248 + 249 + var stderr bytes.Buffer 250 + cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 251 + cmd.Stderr = &stderr 252 + 253 + head, err := g.r.Head() 254 + if err != nil { 255 + return plumbing.ZeroHash, err 256 + } 257 + log.Println("head before apply", head.Hash().String()) 258 + 259 + if err := cmd.Run(); err != nil { 260 + return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 261 + } 262 + 263 + if err := g.Refresh(); err != nil { 264 + return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 265 + } 266 + 267 + head, err = g.r.Head() 268 + if err != nil { 269 + return plumbing.ZeroHash, err 270 + } 271 + log.Println("head after apply", head.Hash().String()) 272 + 273 + newHash := head.Hash() 274 + if changeId, err := singlePatch.ChangeId(); err != nil { 275 + // no change ID 276 + } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 277 + return plumbing.ZeroHash, err 278 + } else { 279 + newHash = updatedHash 280 + } 281 + 282 + return newHash, nil 283 + } 284 + 285 + func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 286 + log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 287 + obj, err := g.r.CommitObject(hash) 288 + if err != nil { 289 + return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 290 + } 291 + 292 + // write the change-id header 293 + obj.ExtraHeaders["change-id"] = []byte(changeId) 294 + 295 + // create a new object 296 + dest := g.r.Storer.NewEncodedObject() 297 + if err := obj.Encode(dest); err != nil { 298 + return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 299 + } 300 + 301 + // store the new object 302 + newHash, err := g.r.Storer.SetEncodedObject(dest) 303 + if err != nil { 304 + return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 305 + } 306 + 307 + log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 308 + 309 + // find the branch that HEAD is pointing to 310 + ref, err := g.r.Head() 311 + if err != nil { 312 + return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 313 + } 314 + 315 + // and update that branch to point to new commit 316 + if ref.Name().IsBranch() { 317 + err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 318 + if err != nil { 319 + return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 320 + } 321 + } 322 + 323 + // new hash of commit 324 + return newHash, nil 325 + } 326 + 327 + func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 329 return val 330 } ··· 371 } 372 defer os.RemoveAll(tmpDir) 373 374 + tmpRepo, err := PlainOpen(tmpDir) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 380 return err 381 } 382
+1 -1
knotserver/xrpc/merge_check.go
··· 51 return 52 } 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 56 response := tangled.RepoMergeCheck_Output{ 57 Is_conflicted: false,
··· 51 return 52 } 53 54 + err = gr.MergeCheck(data.Patch, data.Branch) 55 56 response := tangled.RepoMergeCheck_Output{ 57 Is_conflicted: false,
+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
··· 110 slices.Reverse(branches) 111 return branches, nil 112 }
··· 110 slices.Reverse(branches) 111 return branches, nil 112 } 113 + 114 + func (g *GitRepo) DeleteBranch(branch string) error { 115 + ref := plumbing.NewBranchReferenceName(branch) 116 + return g.r.Storer.RemoveReference(ref) 117 + }
+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 +
+1
appview/repo/router.go
··· 19 }) 20 r.Get("/commit/{ref}", rp.RepoCommit) 21 r.Get("/branches", rp.RepoBranches) 22 r.Route("/tags", func(r chi.Router) { 23 r.Get("/", rp.RepoTags) 24 r.Route("/{tag}", func(r chi.Router) {
··· 19 }) 20 r.Get("/commit/{ref}", rp.RepoCommit) 21 r.Get("/branches", rp.RepoBranches) 22 + r.Delete("/branches", rp.DeleteBranch) 23 r.Route("/tags", func(r chi.Router) { 24 r.Get("/", rp.RepoTags) 25 r.Route("/{tag}", func(r chi.Router) {
+11
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 33 <span>comment</span> 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 </button> 36 {{ if and $isPushAllowed $isOpen $isLastRound }} 37 {{ $disabled := "" }} 38 {{ if $isConflicted }}
··· 33 <span>comment</span> 34 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 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 }} 47 {{ if and $isPushAllowed $isOpen $isLastRound }} 48 {{ $disabled := "" }} 49 {{ if $isConflicted }}
+3 -3
appview/pages/templates/repo/commit.html
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
··· 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 + <header class="col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 88 {{ define "mainLayout" }} 89 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 {{ block "contentLayout" . }} 91 {{ block "content" . }}{{ end }} 92 {{ end }} ··· 105 {{ end }} 106 107 {{ define "footerLayout" }} 108 + <footer class="col-span-full mt-12"> 109 {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }}
+1 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 28 29 {{ end }} 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 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 39 {{ block "contentLayout" . }} 40 {{ block "content" . }}{{ end }} 41 {{ end }} ··· 53 </div> 54 {{ end }} 55 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 {{ define "contentAfter" }} 64 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }} 65 {{end}}
··· 28 29 {{ end }} 30 31 {{ define "mainLayout" }} 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 33 {{ block "contentLayout" . }} 34 {{ block "content" . }}{{ end }} 35 {{ end }} ··· 47 </div> 48 {{ end }} 49 50 {{ define "contentAfter" }} 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }} 52 {{end}}
+1 -1
appview/pages/templates/layouts/fragments/topbar.html
··· 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"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
··· 1 {{ define "layouts/fragments/topbar" }} 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 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 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
+21 -2
knotserver/git/last_commit.go
··· 30 commitCache = cache 31 } 32 33 - func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) { 34 args := []string{} 35 args = append(args, "log") 36 args = append(args, g.h.String()) ··· 48 return nil, err 49 } 50 51 - return stdout, nil 52 } 53 54 type commit struct { ··· 104 if err != nil { 105 return nil, err 106 } 107 108 reader := bufio.NewReader(output) 109 var current commit
··· 30 commitCache = cache 31 } 32 33 + // processReader wraps a reader and ensures the associated process is cleaned up 34 + type processReader struct { 35 + io.Reader 36 + cmd *exec.Cmd 37 + stdout io.ReadCloser 38 + } 39 + 40 + func (pr *processReader) Close() error { 41 + if err := pr.stdout.Close(); err != nil { 42 + return err 43 + } 44 + return pr.cmd.Wait() 45 + } 46 + 47 + func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) { 48 args := []string{} 49 args = append(args, "log") 50 args = append(args, g.h.String()) ··· 62 return nil, err 63 } 64 65 + return &processReader{ 66 + Reader: stdout, 67 + cmd: cmd, 68 + stdout: stdout, 69 + }, nil 70 } 71 72 type commit struct { ··· 122 if err != nil { 123 return nil, err 124 } 125 + defer output.Close() // Ensure the git process is properly cleaned up 126 127 reader := bufio.NewReader(output) 128 var current commit
+18 -18
knotserver/git.go
··· 13 "tangled.org/core/knotserver/git/service" 14 ) 15 16 - func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) 20 if err != nil { 21 gitError(w, "repository not found", http.StatusNotFound) 22 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 return 24 } 25 26 - repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName) 27 if err != nil { 28 gitError(w, "repository not found", http.StatusNotFound) 29 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 return 31 } 32 ··· 46 47 if err := cmd.InfoRefs(); err != nil { 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 - d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 return 51 } 52 case "git-receive-pack": 53 - d.RejectPush(w, r, name) 54 default: 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 } 57 } 58 59 - func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 - repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 63 if err != nil { 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 - d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 return 67 } 68 ··· 77 gzipReader, err := gzip.NewReader(r.Body) 78 if err != nil { 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 - d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 return 82 } 83 defer gzipReader.Close() ··· 88 w.Header().Set("Connection", "Keep-Alive") 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 91 - d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 93 cmd := service.ServiceCommand{ 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 w.WriteHeader(http.StatusOK) 101 102 if err := cmd.UploadPack(); err != nil { 103 - d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 return 105 } 106 } 107 108 - func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 - _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 112 if err != nil { 113 gitError(w, err.Error(), http.StatusForbidden) 114 - d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 return 116 } 117 118 - d.RejectPush(w, r, name) 119 } 120 121 - func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 - hostname := d.c.Server.Hostname 135 if strings.Contains(hostname, ":") { 136 hostname = strings.Split(hostname, ":")[0] 137 }
··· 13 "tangled.org/core/knotserver/git/service" 14 ) 15 16 + func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) 20 if err != nil { 21 gitError(w, "repository not found", http.StatusNotFound) 22 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 return 24 } 25 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 if err != nil { 28 gitError(w, "repository not found", http.StatusNotFound) 29 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 return 31 } 32 ··· 46 47 if err := cmd.InfoRefs(); err != nil { 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 + h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 return 51 } 52 case "git-receive-pack": 53 + h.RejectPush(w, r, name) 54 default: 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 } 57 } 58 59 + func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 if err != nil { 64 gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 return 67 } 68 ··· 77 gzipReader, err := gzip.NewReader(r.Body) 78 if err != nil { 79 gitError(w, err.Error(), http.StatusInternalServerError) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 return 82 } 83 defer gzipReader.Close() ··· 88 w.Header().Set("Connection", "Keep-Alive") 89 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 91 + h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 93 cmd := service.ServiceCommand{ 94 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 w.WriteHeader(http.StatusOK) 101 102 if err := cmd.UploadPack(); err != nil { 103 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 return 105 } 106 } 107 108 + func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 112 if err != nil { 113 gitError(w, err.Error(), http.StatusForbidden) 114 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 return 116 } 117 118 + h.RejectPush(w, r, name) 119 } 120 121 + func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 + hostname := h.c.Server.Hostname 135 if strings.Contains(hostname, ":") { 136 hostname = strings.Split(hostname, ":")[0] 137 }
+4 -1
knotserver/internal.go
··· 20 "tangled.org/core/knotserver/config" 21 "tangled.org/core/knotserver/db" 22 "tangled.org/core/knotserver/git" 23 "tangled.org/core/notifier" 24 "tangled.org/core/rbac" 25 "tangled.org/core/workflow" ··· 314 return h.db.InsertEvent(event, h.n) 315 } 316 317 - func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 318 r := chi.NewRouter() 319 320 h := InternalHandle{ 321 db,
··· 20 "tangled.org/core/knotserver/config" 21 "tangled.org/core/knotserver/db" 22 "tangled.org/core/knotserver/git" 23 + "tangled.org/core/log" 24 "tangled.org/core/notifier" 25 "tangled.org/core/rbac" 26 "tangled.org/core/workflow" ··· 315 return h.db.InsertEvent(event, h.n) 316 } 317 318 + func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 319 r := chi.NewRouter() 320 + l := log.FromContext(ctx) 321 + l = log.SubLogger(l, "internal") 322 323 h := InternalHandle{ 324 db,
+16 -9
knotserver/router.go
··· 12 "tangled.org/core/knotserver/config" 13 "tangled.org/core/knotserver/db" 14 "tangled.org/core/knotserver/xrpc" 15 - tlog "tangled.org/core/log" 16 "tangled.org/core/notifier" 17 "tangled.org/core/rbac" 18 "tangled.org/core/xrpc/serviceauth" ··· 28 resolver *idresolver.Resolver 29 } 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 - 34 h := Knot{ 35 c: c, 36 db: db, 37 e: e, 38 - l: l, 39 jc: jc, 40 n: n, 41 resolver: idresolver.DefaultResolver(), ··· 67 return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 } 69 70 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 }) ··· 86 // Socket that streams git oplogs 87 r.Get("/events", h.Events) 88 89 - return r, nil 90 } 91 92 func (h *Knot) XrpcRouter() http.Handler { 93 - logger := tlog.New("knots") 94 - 95 serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 96 97 xrpc := &xrpc.Xrpc{ 98 Config: h.c, 99 Db: h.db, 100 Ingester: h.jc, 101 Enforcer: h.e, 102 - Logger: logger, 103 Notifier: h.n, 104 Resolver: h.resolver, 105 ServiceAuth: serviceAuth, 106 } 107 return xrpc.Router() 108 } 109
··· 12 "tangled.org/core/knotserver/config" 13 "tangled.org/core/knotserver/db" 14 "tangled.org/core/knotserver/xrpc" 15 + "tangled.org/core/log" 16 "tangled.org/core/notifier" 17 "tangled.org/core/rbac" 18 "tangled.org/core/xrpc/serviceauth" ··· 28 resolver *idresolver.Resolver 29 } 30 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) { 32 h := Knot{ 33 c: c, 34 db: db, 35 e: e, 36 + l: log.FromContext(ctx), 37 jc: jc, 38 n: n, 39 resolver: idresolver.DefaultResolver(), ··· 65 return nil, fmt.Errorf("failed to start jetstream: %w", err) 66 } 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 + 76 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 77 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 78 }) ··· 92 // Socket that streams git oplogs 93 r.Get("/events", h.Events) 94 95 + return r 96 } 97 98 func (h *Knot) XrpcRouter() http.Handler { 99 serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 100 101 + l := log.SubLogger(h.l, "xrpc") 102 + 103 xrpc := &xrpc.Xrpc{ 104 Config: h.c, 105 Db: h.db, 106 Ingester: h.jc, 107 Enforcer: h.e, 108 + Logger: l, 109 Notifier: h.n, 110 Resolver: h.resolver, 111 ServiceAuth: serviceAuth, 112 } 113 + 114 return xrpc.Router() 115 } 116
+5 -4
knotserver/server.go
··· 43 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 logger := log.FromContext(ctx) 46 - iLogger := log.New("knotserver/internal") 47 48 c, err := config.Load(ctx) 49 if err != nil { ··· 80 tangled.KnotMemberNSID, 81 tangled.RepoPullNSID, 82 tangled.RepoCollaboratorNSID, 83 - }, nil, logger, db, true, c.Server.LogDids) 84 if err != nil { 85 logger.Error("failed to setup jetstream", "error", err) 86 } 87 88 notifier := notifier.New() 89 90 - mux, err := Setup(ctx, c, db, e, jc, logger, &notifier) 91 if err != nil { 92 return fmt.Errorf("failed to setup server: %w", err) 93 } 94 95 - imux := Internal(ctx, c, db, e, iLogger, &notifier) 96 97 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 98 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
··· 43 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 logger := log.FromContext(ctx) 46 + logger = log.SubLogger(logger, cmd.Name) 47 + ctx = log.IntoContext(ctx, logger) 48 49 c, err := config.Load(ctx) 50 if err != nil { ··· 81 tangled.KnotMemberNSID, 82 tangled.RepoPullNSID, 83 tangled.RepoCollaboratorNSID, 84 + }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 85 if err != nil { 86 logger.Error("failed to setup jetstream", "error", err) 87 } 88 89 notifier := notifier.New() 90 91 + mux, err := Setup(ctx, c, db, e, jc, &notifier) 92 if err != nil { 93 return fmt.Errorf("failed to setup server: %w", err) 94 } 95 96 + imux := Internal(ctx, c, db, e, &notifier) 97 98 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 99 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
+1 -1
appview/ingester.go
··· 89 } 90 91 if err != nil { 92 - l.Debug("error ingesting record", "err", err) 93 } 94 95 return nil
··· 89 } 90 91 if err != nil { 92 + l.Warn("refused to ingest record", "err", err) 93 } 94 95 return nil
+27 -26
appview/issues/issues.go
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 - "log" 9 "log/slog" 10 "net/http" 11 "slices" ··· 28 "tangled.org/core/appview/reporesolver" 29 "tangled.org/core/appview/validator" 30 "tangled.org/core/idresolver" 31 - tlog "tangled.org/core/log" 32 "tangled.org/core/tid" 33 ) 34 ··· 53 config *config.Config, 54 notifier notify.Notifier, 55 validator *validator.Validator, 56 ) *Issues { 57 return &Issues{ 58 oauth: oauth, ··· 62 db: db, 63 config: config, 64 notifier: notifier, 65 - logger: tlog.New("issues"), 66 validator: validator, 67 } 68 } ··· 72 user := rp.oauth.GetUser(r) 73 f, err := rp.repoResolver.Resolve(r) 74 if err != nil { 75 - log.Println("failed to get repo and knot", err) 76 return 77 } 78 ··· 99 db.FilterContains("scope", tangled.RepoIssueNSID), 100 ) 101 if err != nil { 102 - log.Println("failed to fetch labels", err) 103 rp.pages.Error503(w) 104 return 105 } ··· 126 user := rp.oauth.GetUser(r) 127 f, err := rp.repoResolver.Resolve(r) 128 if err != nil { 129 - log.Println("failed to get repo and knot", err) 130 return 131 } 132 ··· 199 200 err = db.PutIssue(tx, newIssue) 201 if err != nil { 202 - log.Println("failed to edit issue", err) 203 rp.pages.Notice(w, "issues", "Failed to edit issue.") 204 return 205 } ··· 237 // delete from PDS 238 client, err := rp.oauth.AuthorizedClient(r) 239 if err != nil { 240 - log.Println("failed to get authorized client", err) 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 return 243 } ··· 282 283 collaborators, err := f.Collaborators(r.Context()) 284 if err != nil { 285 - log.Println("failed to fetch repo collaborators: %w", err) 286 } 287 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 288 return user.Did == collab.Did ··· 296 db.FilterEq("id", issue.Id), 297 ) 298 if err != nil { 299 - log.Println("failed to close issue", err) 300 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 301 return 302 } ··· 307 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 308 return 309 } else { 310 - log.Println("user is not permitted to close issue") 311 http.Error(w, "for biden", http.StatusUnauthorized) 312 return 313 } ··· 318 user := rp.oauth.GetUser(r) 319 f, err := rp.repoResolver.Resolve(r) 320 if err != nil { 321 - log.Println("failed to get repo and knot", err) 322 return 323 } 324 ··· 331 332 collaborators, err := f.Collaborators(r.Context()) 333 if err != nil { 334 - log.Println("failed to fetch repo collaborators: %w", err) 335 } 336 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 337 return user.Did == collab.Did ··· 344 db.FilterEq("id", issue.Id), 345 ) 346 if err != nil { 347 - log.Println("failed to reopen issue", err) 348 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 349 return 350 } 351 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 352 return 353 } else { 354 - log.Println("user is not the owner of the repo") 355 http.Error(w, "forbidden", http.StatusUnauthorized) 356 return 357 } ··· 538 newBody := r.FormValue("body") 539 client, err := rp.oauth.AuthorizedClient(r) 540 if err != nil { 541 - log.Println("failed to get authorized client", err) 542 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 543 return 544 } ··· 551 552 _, err = db.AddIssueComment(rp.db, newComment) 553 if err != nil { 554 - log.Println("failed to perferom update-description query", err) 555 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 556 return 557 } ··· 561 // update the record on pds 562 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 if err != nil { 564 - log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 return 567 } ··· 729 if comment.Rkey != "" { 730 client, err := rp.oauth.AuthorizedClient(r) 731 if err != nil { 732 - log.Println("failed to get authorized client", err) 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 return 735 } ··· 739 Rkey: comment.Rkey, 740 }) 741 if err != nil { 742 - log.Println(err) 743 } 744 } 745 ··· 757 } 758 759 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 760 params := r.URL.Query() 761 state := params.Get("state") 762 isOpen := true ··· 771 772 page, ok := r.Context().Value("page").(pagination.Page) 773 if !ok { 774 - log.Println("failed to get page") 775 page = pagination.FirstPage() 776 } 777 778 user := rp.oauth.GetUser(r) 779 f, err := rp.repoResolver.Resolve(r) 780 if err != nil { 781 - log.Println("failed to get repo and knot", err) 782 return 783 } 784 ··· 793 db.FilterEq("open", openVal), 794 ) 795 if err != nil { 796 - log.Println("failed to get issues", err) 797 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 798 return 799 } ··· 804 db.FilterContains("scope", tangled.RepoIssueNSID), 805 ) 806 if err != nil { 807 - log.Println("failed to fetch labels", err) 808 rp.pages.Error503(w) 809 return 810 } ··· 901 902 err = db.PutIssue(tx, issue) 903 if err != nil { 904 - log.Println("failed to create issue", err) 905 rp.pages.Notice(w, "issues", "Failed to create issue.") 906 return 907 } 908 909 if err = tx.Commit(); err != nil { 910 - log.Println("failed to create issue", err) 911 rp.pages.Notice(w, "issues", "Failed to create issue.") 912 return 913 }
··· 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "slices" ··· 27 "tangled.org/core/appview/reporesolver" 28 "tangled.org/core/appview/validator" 29 "tangled.org/core/idresolver" 30 "tangled.org/core/tid" 31 ) 32 ··· 51 config *config.Config, 52 notifier notify.Notifier, 53 validator *validator.Validator, 54 + logger *slog.Logger, 55 ) *Issues { 56 return &Issues{ 57 oauth: oauth, ··· 61 db: db, 62 config: config, 63 notifier: notifier, 64 + logger: logger, 65 validator: validator, 66 } 67 } ··· 71 user := rp.oauth.GetUser(r) 72 f, err := rp.repoResolver.Resolve(r) 73 if err != nil { 74 + l.Error("failed to get repo and knot", "err", err) 75 return 76 } 77 ··· 98 db.FilterContains("scope", tangled.RepoIssueNSID), 99 ) 100 if err != nil { 101 + l.Error("failed to fetch labels", "err", err) 102 rp.pages.Error503(w) 103 return 104 } ··· 125 user := rp.oauth.GetUser(r) 126 f, err := rp.repoResolver.Resolve(r) 127 if err != nil { 128 + l.Error("failed to get repo and knot", "err", err) 129 return 130 } 131 ··· 198 199 err = db.PutIssue(tx, newIssue) 200 if err != nil { 201 + l.Error("failed to edit issue", "err", err) 202 rp.pages.Notice(w, "issues", "Failed to edit issue.") 203 return 204 } ··· 236 // delete from PDS 237 client, err := rp.oauth.AuthorizedClient(r) 238 if err != nil { 239 + l.Error("failed to get authorized client", "err", err) 240 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 241 return 242 } ··· 281 282 collaborators, err := f.Collaborators(r.Context()) 283 if err != nil { 284 + l.Error("failed to fetch repo collaborators", "err", err) 285 } 286 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 287 return user.Did == collab.Did ··· 295 db.FilterEq("id", issue.Id), 296 ) 297 if err != nil { 298 + l.Error("failed to close issue", "err", err) 299 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 300 return 301 } ··· 306 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 307 return 308 } else { 309 + l.Error("user is not permitted to close issue") 310 http.Error(w, "for biden", http.StatusUnauthorized) 311 return 312 } ··· 317 user := rp.oauth.GetUser(r) 318 f, err := rp.repoResolver.Resolve(r) 319 if err != nil { 320 + l.Error("failed to get repo and knot", "err", err) 321 return 322 } 323 ··· 330 331 collaborators, err := f.Collaborators(r.Context()) 332 if err != nil { 333 + l.Error("failed to fetch repo collaborators", "err", err) 334 } 335 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 336 return user.Did == collab.Did ··· 343 db.FilterEq("id", issue.Id), 344 ) 345 if err != nil { 346 + l.Error("failed to reopen issue", "err", err) 347 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 348 return 349 } 350 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 351 return 352 } else { 353 + l.Error("user is not the owner of the repo") 354 http.Error(w, "forbidden", http.StatusUnauthorized) 355 return 356 } ··· 537 newBody := r.FormValue("body") 538 client, err := rp.oauth.AuthorizedClient(r) 539 if err != nil { 540 + l.Error("failed to get authorized client", "err", err) 541 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 542 return 543 } ··· 550 551 _, err = db.AddIssueComment(rp.db, newComment) 552 if err != nil { 553 + l.Error("failed to perferom update-description query", "err", err) 554 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 555 return 556 } ··· 560 // update the record on pds 561 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 562 if err != nil { 563 + l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 564 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 565 return 566 } ··· 728 if comment.Rkey != "" { 729 client, err := rp.oauth.AuthorizedClient(r) 730 if err != nil { 731 + l.Error("failed to get authorized client", "err", err) 732 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 733 return 734 } ··· 738 Rkey: comment.Rkey, 739 }) 740 if err != nil { 741 + l.Error("failed to delete from PDS", "err", err) 742 } 743 } 744 ··· 756 } 757 758 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 759 + l := rp.logger.With("handler", "RepoIssues") 760 + 761 params := r.URL.Query() 762 state := params.Get("state") 763 isOpen := true ··· 772 773 page, ok := r.Context().Value("page").(pagination.Page) 774 if !ok { 775 + l.Error("failed to get page") 776 page = pagination.FirstPage() 777 } 778 779 user := rp.oauth.GetUser(r) 780 f, err := rp.repoResolver.Resolve(r) 781 if err != nil { 782 + l.Error("failed to get repo and knot", "err", err) 783 return 784 } 785 ··· 794 db.FilterEq("open", openVal), 795 ) 796 if err != nil { 797 + l.Error("failed to get issues", "err", err) 798 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 799 return 800 } ··· 805 db.FilterContains("scope", tangled.RepoIssueNSID), 806 ) 807 if err != nil { 808 + l.Error("failed to fetch labels", "err", err) 809 rp.pages.Error503(w) 810 return 811 } ··· 902 903 err = db.PutIssue(tx, issue) 904 if err != nil { 905 + l.Error("failed to create issue", "err", err) 906 rp.pages.Notice(w, "issues", "Failed to create issue.") 907 return 908 } 909 910 if err = tx.Commit(); err != nil { 911 + l.Error("failed to create issue", "err", err) 912 rp.pages.Notice(w, "issues", "Failed to create issue.") 913 return 914 }
+15 -12
appview/notifications/notifications.go
··· 1 package notifications 2 3 import ( 4 - "log" 5 "net/http" 6 "strconv" 7 ··· 14 ) 15 16 type Notifications struct { 17 - db *db.DB 18 - oauth *oauth.OAuth 19 - pages *pages.Pages 20 } 21 22 - func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 23 return &Notifications{ 24 - db: database, 25 - oauth: oauthHandler, 26 - pages: pagesHandler, 27 } 28 } 29 ··· 44 } 45 46 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 user := n.oauth.GetUser(r) 48 49 page, ok := r.Context().Value("page").(pagination.Page) 50 if !ok { 51 - log.Println("failed to get page") 52 page = pagination.FirstPage() 53 } 54 ··· 57 db.FilterEq("recipient_did", user.Did), 58 ) 59 if err != nil { 60 - log.Println("failed to get total notifications:", err) 61 n.pages.Error500(w) 62 return 63 } ··· 68 db.FilterEq("recipient_did", user.Did), 69 ) 70 if err != nil { 71 - log.Println("failed to get notifications:", err) 72 n.pages.Error500(w) 73 return 74 } 75 76 err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 77 if err != nil { 78 - log.Println("failed to mark notifications as read:", err) 79 } 80 81 unreadCount := 0
··· 1 package notifications 2 3 import ( 4 + "log/slog" 5 "net/http" 6 "strconv" 7 ··· 14 ) 15 16 type Notifications struct { 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + logger *slog.Logger 21 } 22 23 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications { 24 return &Notifications{ 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 28 + logger: logger, 29 } 30 } 31 ··· 46 } 47 48 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 49 + l := n.logger.With("handler", "notificationsPage") 50 user := n.oauth.GetUser(r) 51 52 page, ok := r.Context().Value("page").(pagination.Page) 53 if !ok { 54 + l.Error("failed to get page") 55 page = pagination.FirstPage() 56 } 57 ··· 60 db.FilterEq("recipient_did", user.Did), 61 ) 62 if err != nil { 63 + l.Error("failed to get total notifications", "err", err) 64 n.pages.Error500(w) 65 return 66 } ··· 71 db.FilterEq("recipient_did", user.Did), 72 ) 73 if err != nil { 74 + l.Error("failed to get notifications", "err", err) 75 n.pages.Error500(w) 76 return 77 } 78 79 err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 80 if err != nil { 81 + l.Error("failed to mark notifications as read", "err", err) 82 } 83 84 unreadCount := 0
+1 -3
appview/pipelines/pipelines.go
··· 16 "tangled.org/core/appview/reporesolver" 17 "tangled.org/core/eventconsumer" 18 "tangled.org/core/idresolver" 19 - "tangled.org/core/log" 20 "tangled.org/core/rbac" 21 spindlemodel "tangled.org/core/spindle/models" 22 ··· 45 db *db.DB, 46 config *config.Config, 47 enforcer *rbac.Enforcer, 48 ) *Pipelines { 49 - logger := log.New("pipelines") 50 - 51 return &Pipelines{ 52 oauth: oauth, 53 repoResolver: repoResolver,
··· 16 "tangled.org/core/appview/reporesolver" 17 "tangled.org/core/eventconsumer" 18 "tangled.org/core/idresolver" 19 "tangled.org/core/rbac" 20 spindlemodel "tangled.org/core/spindle/models" 21 ··· 44 db *db.DB, 45 config *config.Config, 46 enforcer *rbac.Enforcer, 47 + logger *slog.Logger, 48 ) *Pipelines { 49 return &Pipelines{ 50 oauth: oauth, 51 repoResolver: repoResolver,
+4
appview/pulls/pulls.go
··· 6 "errors" 7 "fmt" 8 "log" 9 "net/http" 10 "slices" 11 "sort" ··· 46 config *config.Config 47 notifier notify.Notifier 48 enforcer *rbac.Enforcer 49 } 50 51 func New( ··· 57 config *config.Config, 58 notifier notify.Notifier, 59 enforcer *rbac.Enforcer, 60 ) *Pulls { 61 return &Pulls{ 62 oauth: oauth, ··· 67 config: config, 68 notifier: notifier, 69 enforcer: enforcer, 70 } 71 } 72
··· 6 "errors" 7 "fmt" 8 "log" 9 + "log/slog" 10 "net/http" 11 "slices" 12 "sort" ··· 47 config *config.Config 48 notifier notify.Notifier 49 enforcer *rbac.Enforcer 50 + logger *slog.Logger 51 } 52 53 func New( ··· 59 config *config.Config, 60 notifier notify.Notifier, 61 enforcer *rbac.Enforcer, 62 + logger *slog.Logger, 63 ) *Pulls { 64 return &Pulls{ 65 oauth: oauth, ··· 70 config: config, 71 notifier: notifier, 72 enforcer: enforcer, 73 + logger: logger, 74 } 75 } 76
+3 -1
appview/state/knotstream.go
··· 25 ) 26 27 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 knots, err := db.GetRegistrations( 29 d, 30 db.FilterIsNot("registered", "null"), ··· 39 srcs[s] = struct{}{} 40 } 41 42 - logger := log.New("knotstream") 43 cache := cache.New(c.Redis.Addr) 44 cursorStore := cursor.NewRedisCursorStore(cache) 45
··· 25 ) 26 27 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 28 + logger := log.FromContext(ctx) 29 + logger = log.SubLogger(logger, "knotstream") 30 + 31 knots, err := db.GetRegistrations( 32 d, 33 db.FilterIsNot("registered", "null"), ··· 42 srcs[s] = struct{}{} 43 } 44 45 cache := cache.New(c.Redis.Addr) 46 cursorStore := cursor.NewRedisCursorStore(cache) 47
+14 -9
cmd/appview/main.go
··· 2 3 import ( 4 "context" 5 - "log" 6 - "log/slog" 7 "net/http" 8 "os" 9 10 "tangled.org/core/appview/config" 11 "tangled.org/core/appview/state" 12 ) 13 14 func main() { 15 - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 - 17 ctx := context.Background() 18 19 c, err := config.LoadConfig(ctx) 20 if err != nil { 21 - log.Println("failed to load config", "error", err) 22 return 23 } 24 25 state, err := state.Make(ctx, c) 26 defer func() { 27 - log.Println(state.Close()) 28 }() 29 30 if err != nil { 31 - log.Fatal(err) 32 } 33 34 - log.Println("starting server on", c.Core.ListenAddr) 35 - log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 36 }
··· 2 3 import ( 4 "context" 5 "net/http" 6 "os" 7 8 "tangled.org/core/appview/config" 9 "tangled.org/core/appview/state" 10 + tlog "tangled.org/core/log" 11 ) 12 13 func main() { 14 ctx := context.Background() 15 + logger := tlog.New("appview") 16 + ctx = tlog.IntoContext(ctx, logger) 17 18 c, err := config.LoadConfig(ctx) 19 if err != nil { 20 + logger.Error("failed to load config", "error", err) 21 return 22 } 23 24 state, err := state.Make(ctx, c) 25 defer func() { 26 + if err := state.Close(); err != nil { 27 + logger.Error("failed to close state", "err", err) 28 + } 29 }() 30 31 if err != nil { 32 + logger.Error("failed to start appview", "err", err) 33 + os.Exit(-1) 34 } 35 36 + logger.Info("starting server", "address", c.Core.ListenAddr) 37 + 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 + logger.Error("failed to start appview", "err", err) 40 + } 41 }
+27
go.sum
··· 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 22 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= ··· 48 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 49 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 50 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 120 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 123 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 124 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 125 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 276 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 277 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 278 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 279 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 281 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 282 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 283 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 284 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 285 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 300 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 301 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 302 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 303 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 304 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 305 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 377 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 378 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 379 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 380 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 381 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 382 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 434 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 435 github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 436 github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 437 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 438 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 439 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
··· 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 22 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 23 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 24 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 25 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 26 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= ··· 50 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 51 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 52 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 53 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 54 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 55 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 56 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 57 + github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 58 + github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 59 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 60 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 61 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 62 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 63 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 64 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 65 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 66 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 67 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 134 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 135 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 136 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 137 + github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 138 + github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 139 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 140 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 141 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 292 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 293 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 294 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 295 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 296 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 297 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 298 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 299 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 300 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 301 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 302 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 303 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 304 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 305 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 320 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 321 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 322 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 323 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 324 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 325 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 326 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 327 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 399 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 400 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 401 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 402 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 403 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 404 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 405 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 406 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 407 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 459 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 460 github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 461 github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 462 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 463 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 464 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 465 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 466 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+5 -4
xrpc/serviceauth/service_auth.go
··· 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 "tangled.org/core/idresolver" 12 xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 ··· 22 23 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 return &ServiceAuth{ 25 - logger: logger, 26 resolver: resolver, 27 audienceDid: audienceDid, 28 } ··· 30 31 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 - l := sa.logger.With("url", r.URL) 34 - 35 token := r.Header.Get("Authorization") 36 token = strings.TrimPrefix(token, "Bearer ") 37 ··· 42 43 did, err := s.Validate(r.Context(), token, nil) 44 if err != nil { 45 - l.Error("signature verification failed", "err", err) 46 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 return 48 } 49 50 r = r.WithContext( 51 context.WithValue(r.Context(), ActorDid, did), 52 )
··· 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 "tangled.org/core/idresolver" 12 + "tangled.org/core/log" 13 xrpcerr "tangled.org/core/xrpc/errors" 14 ) 15 ··· 23 24 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 25 return &ServiceAuth{ 26 + logger: log.SubLogger(logger, "serviceauth"), 27 resolver: resolver, 28 audienceDid: audienceDid, 29 } ··· 31 32 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 33 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 token := r.Header.Get("Authorization") 35 token = strings.TrimPrefix(token, "Bearer ") 36 ··· 41 42 did, err := s.Validate(r.Context(), token, nil) 43 if err != nil { 44 + sa.logger.Error("signature verification failed", "err", err) 45 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 46 return 47 } 48 49 + sa.logger.Debug("valid signature", ActorDid, did) 50 + 51 r = r.WithContext( 52 context.WithValue(r.Context(), ActorDid, did), 53 )
+1 -2
cmd/spindle/main.go
··· 7 8 tlog "tangled.org/core/log" 9 "tangled.org/core/spindle" 10 - _ "tangled.org/core/tid" 11 ) 12 13 func main() { 14 - logger := tlog.New("spindl3") 15 slog.SetDefault(logger) 16 17 ctx := context.Background()
··· 7 8 tlog "tangled.org/core/log" 9 "tangled.org/core/spindle" 10 ) 11 12 func main() { 13 + logger := tlog.New("spindle") 14 slog.SetDefault(logger) 15 16 ctx := context.Background()
+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 + }
+1 -1
appview/repo/repo.go
··· 823 824 f, err := rp.repoResolver.Resolve(r) 825 if err != nil { 826 - l.Error("failed to get repo and knot", err) 827 w.WriteHeader(http.StatusBadRequest) 828 return 829 }
··· 823 824 f, err := rp.repoResolver.Resolve(r) 825 if err != nil { 826 + l.Error("failed to get repo and knot", "err", err) 827 w.WriteHeader(http.StatusBadRequest) 828 return 829 }
+1 -1
appview/state/state.go
··· 82 } 83 84 pages := pages.NewPages(config, res, log.SubLogger(logger, "pages")) 85 - oauth, err := oauth.New(config, posthog, d, enforcer, res) 86 if err != nil { 87 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 88 }
··· 82 } 83 84 pages := pages.NewPages(config, res, log.SubLogger(logger, "pages")) 85 + oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth")) 86 if err != nil { 87 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 88 }
+1 -1
.tangled/workflows/build.yml
··· 1 when: 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 4 5 engine: nixery 6
··· 1 when: 2 - event: ["push", "pull_request"] 3 + branch: master 4 5 engine: nixery 6
+1 -1
.tangled/workflows/fmt.yml
··· 1 when: 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 4 5 engine: nixery 6
··· 1 when: 2 - event: ["push", "pull_request"] 3 + branch: master 4 5 engine: nixery 6