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

Compare changes

Choose any two refs to compare.

Changed files
+1572 -2515
.air
api
appview
knotserver
lexicons
nix
spindle
types
+8 -6
.air/appview.toml
···
-
[build]
-
cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go"
-
bin = ";set -o allexport && source .env && set +o allexport; .bin/app"
root = "."
+
tmp_dir = "out"
-
exclude_regex = [".*_templ.go"]
-
include_ext = ["go", "templ", "html", "css"]
-
exclude_dir = ["target", "atrium", "nix"]
+
[build]
+
cmd = "go build -o out/appview.out cmd/appview/main.go"
+
bin = "out/appview.out"
+
+
include_ext = ["go"]
+
exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"]
+
stop_on_error = true
+11
.air/knot.toml
···
+
root = "."
+
tmp_dir = "out"
+
+
[build]
+
cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o out/knot.out cmd/knot/main.go'
+
bin = "out/knot.out"
+
args_bin = ["server"]
+
+
include_ext = ["go"]
+
exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"]
+
stop_on_error = true
-7
.air/knotserver.toml
···
-
[build]
-
cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/'
-
bin = ".bin/knot server"
-
root = "."
-
-
exclude_regex = [""]
-
include_ext = ["go", "templ"]
+10
.air/spindle.toml
···
+
root = "."
+
tmp_dir = "out"
+
+
[build]
+
cmd = "go build -o out/spindle.out cmd/spindle/main.go"
+
bin = "out/spindle.out"
+
+
include_ext = ["go"]
+
exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"]
+
stop_on_error = true
+13
.editorconfig
···
+
root = true
+
+
[*.html]
+
indent_size = 2
+
+
[*.json]
+
indent_size = 2
+
+
[*.nix]
+
indent_size = 2
+
+
[*.yml]
+
indent_size = 2
+8 -649
api/tangled/cbor_gen.go
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
+
fieldCount := 5
if t.Body == nil {
-
fieldCount--
-
}
-
-
if t.Mentions == nil {
-
fieldCount--
-
}
-
-
if t.References == nil {
fieldCount--
···
return err
-
// t.Mentions ([]string) (slice)
-
if t.Mentions != nil {
-
-
if len("mentions") > 1000000 {
-
return xerrors.Errorf("Value in field \"mentions\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("mentions")); err != nil {
-
return err
-
}
-
-
if len(t.Mentions) > 8192 {
-
return xerrors.Errorf("Slice value in field t.Mentions was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
-
return err
-
}
-
for _, v := range t.Mentions {
-
if len(v) > 1000000 {
-
return xerrors.Errorf("Value in field v was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(v)); err != nil {
-
return err
-
}
-
-
}
-
}
-
// t.CreatedAt (string) (string)
if len("createdAt") > 1000000 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
-
-
// t.References ([]string) (slice)
-
if t.References != nil {
-
-
if len("references") > 1000000 {
-
return xerrors.Errorf("Value in field \"references\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("references")); err != nil {
-
return err
-
}
-
-
if len(t.References) > 8192 {
-
return xerrors.Errorf("Slice value in field t.References was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
-
return err
-
}
-
for _, v := range t.References {
-
if len(v) > 1000000 {
-
return xerrors.Errorf("Value in field v was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(v)); err != nil {
-
return err
-
}
-
-
}
-
}
return nil
···
n := extra
-
nameBuf := make([]byte, 10)
+
nameBuf := make([]byte, 9)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
···
t.Title = string(sval)
-
}
-
// t.Mentions ([]string) (slice)
-
case "mentions":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.Mentions = make([]string, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Mentions[i] = string(sval)
-
}
-
-
}
// t.CreatedAt (string) (string)
case "createdAt":
···
t.CreatedAt = string(sval)
-
// t.References ([]string) (slice)
-
case "references":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.References: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.References = make([]string, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.References[i] = string(sval)
-
}
-
-
}
-
}
default:
// Field doesn't exist on this type, so ignore it
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
-
-
if t.Mentions == nil {
-
fieldCount--
-
}
-
-
if t.References == nil {
-
fieldCount--
-
}
+
fieldCount := 5
if t.ReplyTo == nil {
fieldCount--
···
-
// t.Mentions ([]string) (slice)
-
if t.Mentions != nil {
-
-
if len("mentions") > 1000000 {
-
return xerrors.Errorf("Value in field \"mentions\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("mentions")); err != nil {
-
return err
-
}
-
-
if len(t.Mentions) > 8192 {
-
return xerrors.Errorf("Slice value in field t.Mentions was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
-
return err
-
}
-
for _, v := range t.Mentions {
-
if len(v) > 1000000 {
-
return xerrors.Errorf("Value in field v was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(v)); err != nil {
-
return err
-
}
-
-
}
-
}
-
// t.CreatedAt (string) (string)
if len("createdAt") > 1000000 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
-
-
// t.References ([]string) (slice)
-
if t.References != nil {
-
-
if len("references") > 1000000 {
-
return xerrors.Errorf("Value in field \"references\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("references")); err != nil {
-
return err
-
}
-
-
if len(t.References) > 8192 {
-
return xerrors.Errorf("Slice value in field t.References was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
-
return err
-
}
-
for _, v := range t.References {
-
if len(v) > 1000000 {
-
return xerrors.Errorf("Value in field v was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(v)); err != nil {
-
return err
-
}
-
-
}
-
}
return nil
···
n := extra
-
nameBuf := make([]byte, 10)
+
nameBuf := make([]byte, 9)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
···
t.ReplyTo = (*string)(&sval)
-
}
-
}
-
// t.Mentions ([]string) (slice)
-
case "mentions":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.Mentions = make([]string, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Mentions[i] = string(sval)
-
}
-
// t.CreatedAt (string) (string)
···
t.CreatedAt = string(sval)
-
}
-
// t.References ([]string) (slice)
-
case "references":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.References: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.References = make([]string, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.References[i] = string(sval)
-
}
-
-
}
default:
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 9
+
fieldCount := 7
if t.Body == nil {
-
fieldCount--
-
}
-
-
if t.Mentions == nil {
-
fieldCount--
-
}
-
-
if t.References == nil {
fieldCount--
···
return err
-
// t.Mentions ([]string) (slice)
-
if t.Mentions != nil {
-
-
if len("mentions") > 1000000 {
-
return xerrors.Errorf("Value in field \"mentions\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("mentions")); err != nil {
-
return err
-
}
-
-
if len(t.Mentions) > 8192 {
-
return xerrors.Errorf("Slice value in field t.Mentions was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
-
return err
-
}
-
for _, v := range t.Mentions {
-
if len(v) > 1000000 {
-
return xerrors.Errorf("Value in field v was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(v)); err != nil {
-
return err
-
}
-
-
}
-
}
-
// t.CreatedAt (string) (string)
if len("createdAt") > 1000000 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
-
-
// t.References ([]string) (slice)
-
if t.References != nil {
-
-
if len("references") > 1000000 {
-
return xerrors.Errorf("Value in field \"references\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("references")); err != nil {
-
return err
-
}
-
-
if len(t.References) > 8192 {
-
return xerrors.Errorf("Slice value in field t.References was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
-
return err
-
}
-
for _, v := range t.References {
-
if len(v) > 1000000 {
-
return xerrors.Errorf("Value in field v was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(v)); err != nil {
-
return err
-
}
-
-
}
-
}
return nil
···
n := extra
-
nameBuf := make([]byte, 10)
+
nameBuf := make([]byte, 9)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
···
-
}
-
// t.Mentions ([]string) (slice)
-
case "mentions":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.Mentions = make([]string, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Mentions[i] = string(sval)
-
}
-
-
}
// t.CreatedAt (string) (string)
case "createdAt":
···
t.CreatedAt = string(sval)
-
// t.References ([]string) (slice)
-
case "references":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.References: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.References = make([]string, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.References[i] = string(sval)
-
}
-
-
}
-
}
default:
// Field doesn't exist on this type, so ignore it
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 6
-
if t.Mentions == nil {
-
fieldCount--
-
}
-
-
if t.References == nil {
-
fieldCount--
-
}
-
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
if _, err := cw.Write([]byte{164}); err != nil {
return err
···
return err
-
// t.Mentions ([]string) (slice)
-
if t.Mentions != nil {
-
-
if len("mentions") > 1000000 {
-
return xerrors.Errorf("Value in field \"mentions\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("mentions")); err != nil {
-
return err
-
}
-
-
if len(t.Mentions) > 8192 {
-
return xerrors.Errorf("Slice value in field t.Mentions was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil {
-
return err
-
}
-
for _, v := range t.Mentions {
-
if len(v) > 1000000 {
-
return xerrors.Errorf("Value in field v was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(v)); err != nil {
-
return err
-
}
-
-
}
-
}
-
// t.CreatedAt (string) (string)
if len("createdAt") > 1000000 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
-
}
-
-
// t.References ([]string) (slice)
-
if t.References != nil {
-
-
if len("references") > 1000000 {
-
return xerrors.Errorf("Value in field \"references\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("references")); err != nil {
-
return err
-
}
-
-
if len(t.References) > 8192 {
-
return xerrors.Errorf("Slice value in field t.References was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil {
-
return err
-
}
-
for _, v := range t.References {
-
if len(v) > 1000000 {
-
return xerrors.Errorf("Value in field v was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(v)); err != nil {
-
return err
-
}
-
-
}
return nil
···
n := extra
-
nameBuf := make([]byte, 10)
+
nameBuf := make([]byte, 9)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
···
t.LexiconTypeID = string(sval)
-
// t.Mentions ([]string) (slice)
-
case "mentions":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.Mentions: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.Mentions = make([]string, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Mentions[i] = string(sval)
-
}
-
-
}
-
}
// t.CreatedAt (string) (string)
case "createdAt":
···
t.CreatedAt = string(sval)
-
}
-
// t.References ([]string) (slice)
-
case "references":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.References: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.References = make([]string, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.References[i] = string(sval)
-
}
-
-
}
default:
+5 -7
api/tangled/issuecomment.go
···
} //
// RECORDTYPE: RepoIssueComment
type RepoIssueComment struct {
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
-
Body string `json:"body" cborgen:"body"`
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
-
Issue string `json:"issue" cborgen:"issue"`
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
-
ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
+
Body string `json:"body" cborgen:"body"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Issue string `json:"issue" cborgen:"issue"`
+
ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
}
+4 -6
api/tangled/pullcomment.go
···
} //
// RECORDTYPE: RepoPullComment
type RepoPullComment struct {
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
-
Body string `json:"body" cborgen:"body"`
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
-
Pull string `json:"pull" cborgen:"pull"`
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
+
Body string `json:"body" cborgen:"body"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Pull string `json:"pull" cborgen:"pull"`
}
+13 -1
api/tangled/repoblob.go
···
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
type RepoBlob_Output struct {
// content: File content (base64 encoded for binary files)
-
Content string `json:"content" cborgen:"content"`
+
Content *string `json:"content,omitempty" cborgen:"content,omitempty"`
// encoding: Content encoding
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
// isBinary: Whether the file is binary
···
Ref string `json:"ref" cborgen:"ref"`
// size: File size in bytes
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
+
// submodule: Submodule information if path is a submodule
+
Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"`
}
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
···
Name string `json:"name" cborgen:"name"`
// when: Author timestamp
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema.
+
type RepoBlob_Submodule struct {
+
// branch: Branch to track in the submodule
+
Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"`
+
// name: Submodule name
+
Name string `json:"name" cborgen:"name"`
+
// url: Submodule repository URL
+
Url string `json:"url" cborgen:"url"`
}
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
+5 -7
api/tangled/repoissue.go
···
} //
// RECORDTYPE: RepoIssue
type RepoIssue struct {
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
-
Repo string `json:"repo" cborgen:"repo"`
-
Title string `json:"title" cborgen:"title"`
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Repo string `json:"repo" cborgen:"repo"`
+
Title string `json:"title" cborgen:"title"`
}
-2
api/tangled/repopull.go
···
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
Patch string `json:"patch" cborgen:"patch"`
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
Target *RepoPull_Target `json:"target" cborgen:"target"`
Title string `json:"title" cborgen:"title"`
-4
api/tangled/repotree.go
···
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
type RepoTree_TreeEntry struct {
-
// is_file: Whether this entry is a file
-
Is_file bool `json:"is_file" cborgen:"is_file"`
-
// is_subtree: Whether this entry is a directory/subtree
-
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
// mode: File mode
Mode string `json:"mode" cborgen:"mode"`
-9
appview/db/db.go
···
email_notifications integer not null default 0
);
-
create table if not exists reference_links (
-
id integer primary key autoincrement,
-
from_at text not null,
-
to_at text not null,
-
unique (from_at, to_at)
-
);
-
create table if not exists migrations (
id integer primary key autoincrement,
name text unique
···
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
create index if not exists idx_stars_created on stars(created);
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
-
create index if not exists idx_references_from_at on reference_links(from_at);
-
create index if not exists idx_references_to_at on reference_links(to_at);
`)
if err != nil {
return nil, err
+18 -73
appview/db/issues.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
-
"tangled.org/core/api/tangled"
"tangled.org/core/appview/models"
"tangled.org/core/appview/pagination"
)
···
returning rowid, issue_id
`, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
-
err = row.Scan(&issue.Id, &issue.IssueId)
-
if err != nil {
-
return fmt.Errorf("scan row: %w", err)
-
}
-
-
if err := putReferences(tx, issue.AtUri(), issue.References); err != nil {
-
return fmt.Errorf("put reference_links: %w", err)
-
}
-
return nil
+
return row.Scan(&issue.Id, &issue.IssueId)
}
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
···
set title = ?, body = ?, edited = ?
where did = ? and rkey = ?
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
-
if err != nil {
-
return err
-
}
-
-
if err := putReferences(tx, issue.AtUri(), issue.References); err != nil {
-
return fmt.Errorf("put reference_links: %w", err)
-
}
-
return nil
+
return err
}
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
···
}
}
-
// collect references for each issue
-
allReferencs, err := GetReferencesAll(e, FilterIn("from_at", issueAts))
-
if err != nil {
-
return nil, fmt.Errorf("failed to query reference_links: %w", err)
-
}
-
for issueAt, references := range allReferencs {
-
if issue, ok := issueMap[issueAt.String()]; ok {
-
issue.References = references
-
}
-
}
-
var issues []models.Issue
for _, i := range issueMap {
issues = append(issues, *i)
···
return ids, nil
}
-
func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) {
-
result, err := tx.Exec(
+
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
+
result, err := e.Exec(
`insert into issue_comments (
did,
rkey,
···
return 0, err
}
-
if err := putReferences(tx, c.AtUri(), c.References); err != nil {
-
return 0, fmt.Errorf("put reference_links: %w", err)
-
}
-
return id, nil
}
···
}
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
-
commentMap := make(map[string]*models.IssueComment)
+
var comments []models.IssueComment
var conditions []string
var args []any
···
comment.ReplyTo = &replyTo.V
}
-
atUri := comment.AtUri().String()
-
commentMap[atUri] = &comment
+
comments = append(comments, comment)
}
if err = rows.Err(); err != nil {
return nil, err
}
-
// collect references for each comments
-
commentAts := slices.Collect(maps.Keys(commentMap))
-
allReferencs, err := GetReferencesAll(e, FilterIn("from_at", commentAts))
-
if err != nil {
-
return nil, fmt.Errorf("failed to query reference_links: %w", err)
-
}
-
for commentAt, references := range allReferencs {
-
if comment, ok := commentMap[commentAt.String()]; ok {
-
comment.References = references
-
}
-
}
-
-
var comments []models.IssueComment
-
for _, c := range commentMap {
-
comments = append(comments, *c)
-
}
-
-
sort.Slice(comments, func(i, j int) bool {
-
return comments[i].Created.After(comments[j].Created)
-
})
-
return comments, nil
}
-
func DeleteIssues(tx *sql.Tx, did, rkey string) error {
-
_, err := tx.Exec(
-
`delete from issues
-
where did = ? and rkey = ?`,
-
did,
-
rkey,
-
)
-
if err != nil {
-
return fmt.Errorf("delete issue: %w", err)
+
func DeleteIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
uri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoIssueNSID, rkey))
-
err = deleteReferences(tx, uri)
-
if err != nil {
-
return fmt.Errorf("delete reference_links: %w", err)
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
return nil
+
query := fmt.Sprintf(`delete from issues %s`, whereClause)
+
_, err := e.Exec(query, args...)
+
return err
}
func CloseIssues(e Execer, filters ...filter) error {
+4 -2
appview/db/pipeline.go
···
// this is a mega query, but the most useful one:
// get N pipelines, for each one get the latest status of its N workflows
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
+
func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
join
triggers t ON p.trigger_id = t.id
%s
-
`, whereClause)
+
order by p.created desc
+
limit %d
+
`, whereClause, limit)
rows, err := e.Query(query, args...)
if err != nil {
+6 -50
appview/db/pulls.go
···
insert into pull_submissions (pull_at, round_number, patch, combined, source_rev)
values (?, ?, ?, ?, ?)
`, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev)
-
if err != nil {
-
return err
-
}
-
-
if err := putReferences(tx, pull.AtUri(), pull.References); err != nil {
-
return fmt.Errorf("put reference_links: %w", err)
-
}
-
-
return nil
+
return err
}
func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) {
···
}
}
-
allReferences, err := GetReferencesAll(e, FilterIn("from_at", pullAts))
-
if err != nil {
-
return nil, fmt.Errorf("failed to query reference_links: %w", err)
-
}
-
for pullAt, references := range allReferences {
-
if pull, ok := pulls[pullAt]; ok {
-
pull.References = references
-
}
-
}
-
orderedByPullId := []*models.Pull{}
for _, p := range pulls {
orderedByPullId = append(orderedByPullId, p)
···
submissionIds := slices.Collect(maps.Keys(submissionMap))
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
if err != nil {
-
return nil, fmt.Errorf("failed to get pull comments: %w", err)
+
return nil, err
}
for _, comment := range comments {
if submission, ok := submissionMap[comment.SubmissionId]; ok {
···
}
defer rows.Close()
-
commentMap := make(map[string]*models.PullComment)
+
var comments []models.PullComment
for rows.Next() {
var comment models.PullComment
var createdAt string
···
comment.Created = t
}
-
atUri := comment.AtUri().String()
-
commentMap[atUri] = &comment
+
comments = append(comments, comment)
}
if err := rows.Err(); err != nil {
return nil, err
}
-
// collect references for each comments
-
commentAts := slices.Collect(maps.Keys(commentMap))
-
allReferencs, err := GetReferencesAll(e, FilterIn("from_at", commentAts))
-
if err != nil {
-
return nil, fmt.Errorf("failed to query reference_links: %w", err)
-
}
-
for commentAt, references := range allReferencs {
-
if comment, ok := commentMap[commentAt.String()]; ok {
-
comment.References = references
-
}
-
}
-
-
var comments []models.PullComment
-
for _, c := range commentMap {
-
comments = append(comments, *c)
-
}
-
-
sort.Slice(comments, func(i, j int) bool {
-
return comments[i].Created.Before(comments[j].Created)
-
})
-
return comments, nil
}
···
return pulls, nil
}
-
func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) {
+
func NewPullComment(e Execer, comment *models.PullComment) (int64, error) {
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
-
res, err := tx.Exec(
+
res, err := e.Exec(
query,
comment.OwnerDid,
comment.RepoAt,
···
i, err := res.LastInsertId()
if err != nil {
return 0, err
-
}
-
-
if err := putReferences(tx, comment.AtUri(), comment.References); err != nil {
-
return 0, fmt.Errorf("put reference_links: %w", err)
}
return i, nil
-462
appview/db/reference.go
···
-
package db
-
-
import (
-
"database/sql"
-
"fmt"
-
"strings"
-
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
"tangled.org/core/api/tangled"
-
"tangled.org/core/appview/models"
-
)
-
-
// ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs.
-
// It will ignore missing refLinks.
-
func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
-
var (
-
issueRefs []models.ReferenceLink
-
pullRefs []models.ReferenceLink
-
)
-
for _, ref := range refLinks {
-
switch ref.Kind {
-
case models.RefKindIssue:
-
issueRefs = append(issueRefs, ref)
-
case models.RefKindPull:
-
pullRefs = append(pullRefs, ref)
-
}
-
}
-
issueUris, err := findIssueReferences(e, issueRefs)
-
if err != nil {
-
return nil, fmt.Errorf("find issue references: %w", err)
-
}
-
pullUris, err := findPullReferences(e, pullRefs)
-
if err != nil {
-
return nil, fmt.Errorf("find pull references: %w", err)
-
}
-
-
return append(issueUris, pullUris...), nil
-
}
-
-
func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
-
if len(refLinks) == 0 {
-
return nil, nil
-
}
-
vals := make([]string, len(refLinks))
-
args := make([]any, 0, len(refLinks)*4)
-
for i, ref := range refLinks {
-
vals[i] = "(?, ?, ?, ?)"
-
args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId)
-
}
-
query := fmt.Sprintf(
-
`with input(owner_did, name, issue_id, comment_id) as (
-
values %s
-
)
-
select
-
i.did, i.rkey,
-
c.did, c.rkey
-
from input inp
-
join repos r
-
on r.did = inp.owner_did
-
and r.name = inp.name
-
join issues i
-
on i.repo_at = r.at_uri
-
and i.issue_id = inp.issue_id
-
left join issue_comments c
-
on inp.comment_id is not null
-
and c.issue_at = i.at_uri
-
and c.id = inp.comment_id
-
`,
-
strings.Join(vals, ","),
-
)
-
rows, err := e.Query(query, args...)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
var uris []syntax.ATURI
-
-
for rows.Next() {
-
// Scan rows
-
var issueOwner, issueRkey string
-
var commentOwner, commentRkey sql.NullString
-
var uri syntax.ATURI
-
if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil {
-
return nil, err
-
}
-
if commentOwner.Valid && commentRkey.Valid {
-
uri = syntax.ATURI(fmt.Sprintf(
-
"at://%s/%s/%s",
-
commentOwner.String,
-
tangled.RepoIssueCommentNSID,
-
commentRkey.String,
-
))
-
} else {
-
uri = syntax.ATURI(fmt.Sprintf(
-
"at://%s/%s/%s",
-
issueOwner,
-
tangled.RepoIssueNSID,
-
issueRkey,
-
))
-
}
-
uris = append(uris, uri)
-
}
-
if err := rows.Err(); err != nil {
-
return nil, fmt.Errorf("iterate rows: %w", err)
-
}
-
-
return uris, nil
-
}
-
-
func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) {
-
if len(refLinks) == 0 {
-
return nil, nil
-
}
-
vals := make([]string, len(refLinks))
-
args := make([]any, 0, len(refLinks)*4)
-
for i, ref := range refLinks {
-
vals[i] = "(?, ?, ?, ?)"
-
args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId)
-
}
-
query := fmt.Sprintf(
-
`with input(owner_did, name, pull_id, comment_id) as (
-
values %s
-
)
-
select
-
p.owner_did, p.rkey,
-
c.comment_at
-
from input inp
-
join repos r
-
on r.did = inp.owner_did
-
and r.name = inp.name
-
join pulls p
-
on p.repo_at = r.at_uri
-
and p.pull_id = inp.pull_id
-
left join pull_comments c
-
on inp.comment_id is not null
-
and c.repo_at = r.at_uri and c.pull_id = p.pull_id
-
and c.id = inp.comment_id
-
`,
-
strings.Join(vals, ","),
-
)
-
rows, err := e.Query(query, args...)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
var uris []syntax.ATURI
-
-
for rows.Next() {
-
// Scan rows
-
var pullOwner, pullRkey string
-
var commentUri sql.NullString
-
var uri syntax.ATURI
-
if err := rows.Scan(&pullOwner, &pullRkey, &commentUri); err != nil {
-
return nil, err
-
}
-
if commentUri.Valid {
-
// no-op
-
uri = syntax.ATURI(commentUri.String)
-
} else {
-
uri = syntax.ATURI(fmt.Sprintf(
-
"at://%s/%s/%s",
-
pullOwner,
-
tangled.RepoPullNSID,
-
pullRkey,
-
))
-
}
-
uris = append(uris, uri)
-
}
-
return uris, nil
-
}
-
-
func putReferences(tx *sql.Tx, fromAt syntax.ATURI, references []syntax.ATURI) error {
-
err := deleteReferences(tx, fromAt)
-
if err != nil {
-
return fmt.Errorf("delete old reference_links: %w", err)
-
}
-
if len(references) == 0 {
-
return nil
-
}
-
-
values := make([]string, 0, len(references))
-
args := make([]any, 0, len(references)*2)
-
for _, ref := range references {
-
values = append(values, "(?, ?)")
-
args = append(args, fromAt, ref)
-
}
-
_, err = tx.Exec(
-
fmt.Sprintf(
-
`insert into reference_links (from_at, to_at)
-
values %s`,
-
strings.Join(values, ","),
-
),
-
args...,
-
)
-
if err != nil {
-
return fmt.Errorf("insert new reference_links: %w", err)
-
}
-
return nil
-
}
-
-
func deleteReferences(tx *sql.Tx, fromAt syntax.ATURI) error {
-
_, err := tx.Exec(`delete from reference_links where from_at = ?`, fromAt)
-
return err
-
}
-
-
func GetReferencesAll(e Execer, filters ...filter) (map[syntax.ATURI][]syntax.ATURI, error) {
-
var (
-
conditions []string
-
args []any
-
)
-
for _, filter := range filters {
-
conditions = append(conditions, filter.Condition())
-
args = append(args, filter.Arg()...)
-
}
-
-
whereClause := ""
-
if conditions != nil {
-
whereClause = " where " + strings.Join(conditions, " and ")
-
}
-
-
rows, err := e.Query(
-
fmt.Sprintf(
-
`select from_at, to_at from reference_links %s`,
-
whereClause,
-
),
-
args...,
-
)
-
if err != nil {
-
return nil, fmt.Errorf("query reference_links: %w", err)
-
}
-
defer rows.Close()
-
-
result := make(map[syntax.ATURI][]syntax.ATURI)
-
-
for rows.Next() {
-
var from, to syntax.ATURI
-
if err := rows.Scan(&from, &to); err != nil {
-
return nil, fmt.Errorf("scan row: %w", err)
-
}
-
-
result[from] = append(result[from], to)
-
}
-
if err := rows.Err(); err != nil {
-
return nil, fmt.Errorf("iterate rows: %w", err)
-
}
-
-
return result, nil
-
}
-
-
func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) {
-
rows, err := e.Query(
-
`select from_at from reference_links
-
where to_at = ?`,
-
target,
-
)
-
if err != nil {
-
return nil, fmt.Errorf("query backlinks: %w", err)
-
}
-
defer rows.Close()
-
-
var (
-
backlinks []models.RichReferenceLink
-
backlinksMap = make(map[string][]syntax.ATURI)
-
)
-
for rows.Next() {
-
var from syntax.ATURI
-
if err := rows.Scan(&from); err != nil {
-
return nil, fmt.Errorf("scan row: %w", err)
-
}
-
nsid := from.Collection().String()
-
backlinksMap[nsid] = append(backlinksMap[nsid], from)
-
}
-
if err := rows.Err(); err != nil {
-
return nil, fmt.Errorf("iterate rows: %w", err)
-
}
-
-
var ls []models.RichReferenceLink
-
ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID])
-
if err != nil {
-
return nil, fmt.Errorf("get issue backlinks: %w", err)
-
}
-
backlinks = append(backlinks, ls...)
-
ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID])
-
if err != nil {
-
return nil, fmt.Errorf("get issue_comment backlinks: %w", err)
-
}
-
backlinks = append(backlinks, ls...)
-
ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID])
-
if err != nil {
-
return nil, fmt.Errorf("get pull backlinks: %w", err)
-
}
-
backlinks = append(backlinks, ls...)
-
ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID])
-
if err != nil {
-
return nil, fmt.Errorf("get pull_comment backlinks: %w", err)
-
}
-
backlinks = append(backlinks, ls...)
-
-
return backlinks, nil
-
}
-
-
func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
-
if len(aturis) == 0 {
-
return nil, nil
-
}
-
vals := make([]string, len(aturis))
-
args := make([]any, 0, len(aturis)*2)
-
for i, aturi := range aturis {
-
vals[i] = "(?, ?)"
-
did := aturi.Authority().String()
-
rkey := aturi.RecordKey().String()
-
args = append(args, did, rkey)
-
}
-
rows, err := e.Query(
-
fmt.Sprintf(
-
`select r.did, r.name, i.issue_id, i.title, i.open
-
from issues i
-
join repos r
-
on r.at_uri = i.repo_at
-
where (i.did, i.rkey) in (%s)`,
-
strings.Join(vals, ","),
-
),
-
args...,
-
)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
var refLinks []models.RichReferenceLink
-
for rows.Next() {
-
var l models.RichReferenceLink
-
l.Kind = models.RefKindIssue
-
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil {
-
return nil, err
-
}
-
refLinks = append(refLinks, l)
-
}
-
if err := rows.Err(); err != nil {
-
return nil, fmt.Errorf("iterate rows: %w", err)
-
}
-
return refLinks, nil
-
}
-
-
func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
-
if len(aturis) == 0 {
-
return nil, nil
-
}
-
filter := FilterIn("c.at_uri", aturis)
-
rows, err := e.Query(
-
fmt.Sprintf(
-
`select r.did, r.name, i.issue_id, c.id, i.title, i.open
-
from issue_comments c
-
join issues i
-
on i.at_uri = c.issue_at
-
join repos r
-
on r.at_uri = i.repo_at
-
where %s`,
-
filter.Condition(),
-
),
-
filter.Arg()...,
-
)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
var refLinks []models.RichReferenceLink
-
for rows.Next() {
-
var l models.RichReferenceLink
-
l.Kind = models.RefKindIssue
-
l.CommentId = new(int)
-
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil {
-
return nil, err
-
}
-
refLinks = append(refLinks, l)
-
}
-
if err := rows.Err(); err != nil {
-
return nil, fmt.Errorf("iterate rows: %w", err)
-
}
-
return refLinks, nil
-
}
-
-
func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
-
if len(aturis) == 0 {
-
return nil, nil
-
}
-
vals := make([]string, len(aturis))
-
args := make([]any, 0, len(aturis)*2)
-
for i, aturi := range aturis {
-
vals[i] = "(?, ?)"
-
did := aturi.Authority().String()
-
rkey := aturi.RecordKey().String()
-
args = append(args, did, rkey)
-
}
-
rows, err := e.Query(
-
fmt.Sprintf(
-
`select r.did, r.name, p.pull_id, p.title, p.state
-
from pulls p
-
join repos r
-
on r.at_uri = p.repo_at
-
where (p.owner_did, p.rkey) in (%s)`,
-
strings.Join(vals, ","),
-
),
-
args...,
-
)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
var refLinks []models.RichReferenceLink
-
for rows.Next() {
-
var l models.RichReferenceLink
-
l.Kind = models.RefKindPull
-
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil {
-
return nil, err
-
}
-
refLinks = append(refLinks, l)
-
}
-
if err := rows.Err(); err != nil {
-
return nil, fmt.Errorf("iterate rows: %w", err)
-
}
-
return refLinks, nil
-
}
-
-
func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) {
-
if len(aturis) == 0 {
-
return nil, nil
-
}
-
filter := FilterIn("c.comment_at", aturis)
-
rows, err := e.Query(
-
fmt.Sprintf(
-
`select r.did, r.name, p.pull_id, c.id, p.title, p.state
-
from repos r
-
join pulls p
-
on r.at_uri = p.repo_at
-
join pull_comments c
-
on r.at_uri = c.repo_at and p.pull_id = c.pull_id
-
where %s`,
-
filter.Condition(),
-
),
-
filter.Arg()...,
-
)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
var refLinks []models.RichReferenceLink
-
for rows.Next() {
-
var l models.RichReferenceLink
-
l.Kind = models.RefKindPull
-
l.CommentId = new(int)
-
if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil {
-
return nil, err
-
}
-
refLinks = append(refLinks, l)
-
}
-
if err := rows.Err(); err != nil {
-
return nil, fmt.Errorf("iterate rows: %w", err)
-
}
-
return refLinks, nil
-
}
+3 -1
appview/indexer/issues/indexer.go
···
log.Fatalln("failed to populate issue indexer", err)
}
}
-
l.Info("Initialized the issue indexer")
+
+
count, _ := ix.indexer.DocCount()
+
l.Info("Initialized the issue indexer", "docCount", count)
}
func generateIssueIndexMapping() (mapping.IndexMapping, error) {
+3 -1
appview/indexer/pulls/indexer.go
···
log.Fatalln("failed to populate pull indexer", err)
}
}
-
l.Info("Initialized the pull indexer")
+
+
count, _ := ix.indexer.DocCount()
+
l.Info("Initialized the pull indexer", "docCount", count)
}
func generatePullIndexMapping() (mapping.IndexMapping, error) {
+5 -22
appview/ingester.go
···
return nil
case jmodels.CommitOperationDelete:
-
tx, err := ddb.BeginTx(ctx, nil)
-
if err != nil {
-
l.Error("failed to begin transaction", "err", err)
-
return err
-
}
-
defer tx.Rollback()
-
if err := db.DeleteIssues(
-
tx,
-
did,
-
rkey,
+
ddb,
+
db.FilterEq("did", did),
+
db.FilterEq("rkey", rkey),
); err != nil {
l.Error("failed to delete", "err", err)
return fmt.Errorf("failed to delete issue record: %w", err)
-
}
-
if err := tx.Commit(); err != nil {
-
l.Error("failed to commit txn", "err", err)
-
return err
}
return nil
···
return fmt.Errorf("failed to validate comment: %w", err)
}
-
tx, err := ddb.Begin()
-
if err != nil {
-
return fmt.Errorf("failed to start transaction: %w", err)
-
}
-
defer tx.Rollback()
-
-
_, err = db.AddIssueComment(tx, *comment)
+
_, err = db.AddIssueComment(ddb, *comment)
if err != nil {
return fmt.Errorf("failed to create issue comment: %w", err)
}
-
return tx.Commit()
+
return nil
case jmodels.CommitOperationDelete:
if err := db.DeleteIssueComments(
+61 -89
appview/issues/issues.go
···
"tangled.org/core/appview/notify"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
+
"tangled.org/core/appview/pages/markup"
"tangled.org/core/appview/pagination"
-
"tangled.org/core/appview/refresolver"
"tangled.org/core/appview/reporesolver"
"tangled.org/core/appview/validator"
"tangled.org/core/idresolver"
···
repoResolver *reporesolver.RepoResolver
pages *pages.Pages
idResolver *idresolver.Resolver
-
refResolver *refresolver.Resolver
db *db.DB
config *config.Config
notifier notify.Notifier
···
repoResolver *reporesolver.RepoResolver,
pages *pages.Pages,
idResolver *idresolver.Resolver,
-
refResolver *refresolver.Resolver,
db *db.DB,
config *config.Config,
notifier notify.Notifier,
···
repoResolver: repoResolver,
pages: pages,
idResolver: idResolver,
-
refResolver: refResolver,
db: db,
config: config,
notifier: notifier,
···
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
}
-
backlinks, err := db.GetBacklinks(rp.db, issue.AtUri())
-
if err != nil {
-
l.Error("failed to fetch backlinks", "err", err)
-
rp.pages.Error503(w)
-
return
-
}
-
labelDefs, err := db.GetLabelDefinitions(
rp.db,
db.FilterIn("at_uri", f.Repo.Labels),
···
RepoInfo: f.RepoInfo(user),
Issue: issue,
CommentList: issue.CommentList(),
-
Backlinks: backlinks,
OrderedReactionKinds: models.OrderedReactionKinds,
Reactions: reactionMap,
UserReacted: userReactions,
···
newIssue := issue
newIssue.Title = r.FormValue("title")
newIssue.Body = r.FormValue("body")
-
newIssue.Mentions, newIssue.References = rp.refResolver.Resolve(r.Context(), newIssue.Body)
if err := rp.validator.ValidateIssue(newIssue); err != nil {
l.Error("validation error", "err", err)
···
}
l = l.With("did", issue.Did, "rkey", issue.Rkey)
-
tx, err := rp.db.Begin()
-
if err != nil {
-
l.Error("failed to start transaction", "err", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
// delete from PDS
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
···
}
// delete from db
-
if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil {
+
if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
l.Error("failed to delete issue", "err", err)
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
return
}
-
tx.Commit()
rp.notifier.DeleteIssue(r.Context(), issue)
···
replyTo = &replyToUri
}
-
mentions, references := rp.refResolver.Resolve(r.Context(), body)
-
comment := models.IssueComment{
-
Did: user.Did,
-
Rkey: tid.TID(),
-
IssueAt: issue.AtUri().String(),
-
ReplyTo: replyTo,
-
Body: body,
-
Created: time.Now(),
-
Mentions: mentions,
-
References: references,
+
Did: user.Did,
+
Rkey: tid.TID(),
+
IssueAt: issue.AtUri().String(),
+
ReplyTo: replyTo,
+
Body: body,
+
Created: time.Now(),
}
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
l.Error("failed to validate comment", "err", err)
···
}
}()
-
tx, err := rp.db.Begin()
-
if err != nil {
-
l.Error("failed to start transaction", "err", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
commentId, err := db.AddIssueComment(tx, comment)
+
commentId, err := db.AddIssueComment(rp.db, comment)
if err != nil {
l.Error("failed to create comment", "err", err)
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
-
err = tx.Commit()
-
if err != nil {
-
l.Error("failed to commit transaction", "err", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.")
-
return
-
}
// reset atUri to make rollback a no-op
atUri = ""
···
// notify about the new comment
comment.Id = commentId
+
rawMentions := markup.FindUserMentions(comment.Body)
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
+
var mentions []syntax.DID
+
for _, ident := range idents {
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
+
mentions = append(mentions, ident.DID)
+
}
+
}
rp.notifier.NewIssueComment(r.Context(), &comment, mentions)
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
···
newComment := comment
newComment.Body = newBody
newComment.Edited = &now
-
newComment.Mentions, newComment.References = rp.refResolver.Resolve(r.Context(), newBody)
-
record := newComment.AsRecord()
-
tx, err := rp.db.Begin()
-
if err != nil {
-
l.Error("failed to start transaction", "err", err)
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
_, err = db.AddIssueComment(tx, newComment)
+
_, err = db.AddIssueComment(rp.db, newComment)
if err != nil {
l.Error("failed to perferom update-description query", "err", err)
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
return
}
-
tx.Commit()
// rkey is optional, it was introduced later
if newComment.Rkey != "" {
···
keyword := params.Get("q")
-
var ids []int64
+
var issues []models.Issue
searchOpts := models.IssueSearchOptions{
Keyword: keyword,
RepoAt: f.RepoAt().String(),
···
l.Error("failed to search for issues", "err", err)
return
}
-
ids = res.Hits
-
l.Debug("searched issues with indexer", "count", len(ids))
+
l.Debug("searched issues with indexer", "count", len(res.Hits))
+
+
issues, err = db.GetIssues(
+
rp.db,
+
db.FilterIn("id", res.Hits),
+
)
+
if err != nil {
+
l.Error("failed to get issues", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
+
return
+
}
+
} else {
-
ids, err = db.GetIssueIDs(rp.db, searchOpts)
+
openInt := 0
+
if isOpen {
+
openInt = 1
+
}
+
issues, err = db.GetIssuesPaginated(
+
rp.db,
+
page,
+
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterEq("open", openInt),
+
)
if err != nil {
-
l.Error("failed to search for issues", "err", err)
+
l.Error("failed to get issues", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
return
}
-
l.Debug("indexed all issues from the db", "count", len(ids))
-
}
-
-
issues, err := db.GetIssues(
-
rp.db,
-
db.FilterIn("id", ids),
-
)
-
if err != nil {
-
l.Error("failed to get issues", "err", err)
-
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
-
return
}
labelDefs, err := db.GetLabelDefinitions(
···
RepoInfo: f.RepoInfo(user),
})
case http.MethodPost:
-
body := r.FormValue("body")
-
mentions, references := rp.refResolver.Resolve(r.Context(), body)
-
issue := &models.Issue{
-
RepoAt: f.RepoAt(),
-
Rkey: tid.TID(),
-
Title: r.FormValue("title"),
-
Body: body,
-
Open: true,
-
Did: user.Did,
-
Created: time.Now(),
-
Mentions: mentions,
-
References: references,
-
Repo: &f.Repo,
+
RepoAt: f.RepoAt(),
+
Rkey: tid.TID(),
+
Title: r.FormValue("title"),
+
Body: r.FormValue("body"),
+
Open: true,
+
Did: user.Did,
+
Created: time.Now(),
+
Repo: &f.Repo,
}
if err := rp.validator.ValidateIssue(issue); err != nil {
···
// everything is successful, do not rollback the atproto record
atUri = ""
+
rawMentions := markup.FindUserMentions(issue.Body)
+
idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions)
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
+
var mentions []syntax.DID
+
for _, ident := range idents {
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
+
mentions = append(mentions, ident.DID)
+
}
+
}
rp.notifier.NewIssue(r.Context(), issue, mentions)
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
+2
appview/middleware/middleware.go
···
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
repoName := chi.URLParam(req, "repo")
+
repoName = strings.TrimSuffix(repoName, ".git")
+
id, ok := req.Context().Value("resolvedId").(identity.Identity)
if !ok {
log.Println("malformed middleware")
+34 -70
appview/models/issue.go
···
)
type Issue struct {
-
Id int64
-
Did string
-
Rkey string
-
RepoAt syntax.ATURI
-
IssueId int
-
Created time.Time
-
Edited *time.Time
-
Deleted *time.Time
-
Title string
-
Body string
-
Open bool
-
Mentions []syntax.DID
-
References []syntax.ATURI
+
Id int64
+
Did string
+
Rkey string
+
RepoAt syntax.ATURI
+
IssueId int
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
+
Title string
+
Body string
+
Open bool
// optionally, populate this when querying for reverse mappings
// like comment counts, parent repo etc.
···
}
func (i *Issue) AsRecord() tangled.RepoIssue {
-
mentions := make([]string, len(i.Mentions))
-
for i, did := range i.Mentions {
-
mentions[i] = string(did)
-
}
-
references := make([]string, len(i.References))
-
for i, uri := range i.References {
-
references[i] = string(uri)
-
}
return tangled.RepoIssue{
-
Repo: i.RepoAt.String(),
-
Title: i.Title,
-
Body: &i.Body,
-
Mentions: mentions,
-
References: references,
-
CreatedAt: i.Created.Format(time.RFC3339),
+
Repo: i.RepoAt.String(),
+
Title: i.Title,
+
Body: &i.Body,
+
CreatedAt: i.Created.Format(time.RFC3339),
}
}
···
}
type IssueComment struct {
-
Id int64
-
Did string
-
Rkey string
-
IssueAt string
-
ReplyTo *string
-
Body string
-
Created time.Time
-
Edited *time.Time
-
Deleted *time.Time
-
Mentions []syntax.DID
-
References []syntax.ATURI
+
Id int64
+
Did string
+
Rkey string
+
IssueAt string
+
ReplyTo *string
+
Body string
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
}
func (i *IssueComment) AtUri() syntax.ATURI {
···
}
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
-
mentions := make([]string, len(i.Mentions))
-
for i, did := range i.Mentions {
-
mentions[i] = string(did)
-
}
-
references := make([]string, len(i.References))
-
for i, uri := range i.References {
-
references[i] = string(uri)
-
}
return tangled.RepoIssueComment{
-
Body: i.Body,
-
Issue: i.IssueAt,
-
CreatedAt: i.Created.Format(time.RFC3339),
-
ReplyTo: i.ReplyTo,
-
Mentions: mentions,
-
References: references,
+
Body: i.Body,
+
Issue: i.IssueAt,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
ReplyTo: i.ReplyTo,
}
}
···
return nil, err
}
-
i := record
-
mentions := make([]syntax.DID, len(record.Mentions))
-
for i, did := range record.Mentions {
-
mentions[i] = syntax.DID(did)
-
}
-
references := make([]syntax.ATURI, len(record.References))
-
for i, uri := range i.References {
-
references[i] = syntax.ATURI(uri)
-
}
-
comment := IssueComment{
-
Did: ownerDid,
-
Rkey: rkey,
-
Body: record.Body,
-
IssueAt: record.Issue,
-
ReplyTo: record.ReplyTo,
-
Created: created,
-
Mentions: mentions,
-
References: references,
+
Did: ownerDid,
+
Rkey: rkey,
+
Body: record.Body,
+
IssueAt: record.Issue,
+
ReplyTo: record.ReplyTo,
+
Created: created,
}
return &comment, nil
+3 -41
appview/models/pull.go
···
TargetBranch string
State PullState
Submissions []*PullSubmission
-
Mentions []syntax.DID
-
References []syntax.ATURI
// stacking
StackId string // nullable string
···
source.Repo = &s
}
}
-
mentions := make([]string, len(p.Mentions))
-
for i, did := range p.Mentions {
-
mentions[i] = string(did)
-
}
-
references := make([]string, len(p.References))
-
for i, uri := range p.References {
-
references[i] = string(uri)
-
}
record := tangled.RepoPull{
-
Title: p.Title,
-
Body: &p.Body,
-
Mentions: mentions,
-
References: references,
-
CreatedAt: p.Created.Format(time.RFC3339),
+
Title: p.Title,
+
Body: &p.Body,
+
CreatedAt: p.Created.Format(time.RFC3339),
Target: &tangled.RepoPull_Target{
Repo: p.RepoAt.String(),
Branch: p.TargetBranch,
···
// content
Body string
-
-
// meta
-
Mentions []syntax.DID
-
References []syntax.ATURI
// meta
Created time.Time
}
-
-
func (p *PullComment) AtUri() syntax.ATURI {
-
return syntax.ATURI(p.CommentAt)
-
}
-
-
// func (p *PullComment) AsRecord() tangled.RepoPullComment {
-
// mentions := make([]string, len(p.Mentions))
-
// for i, did := range p.Mentions {
-
// mentions[i] = string(did)
-
// }
-
// references := make([]string, len(p.References))
-
// for i, uri := range p.References {
-
// references[i] = string(uri)
-
// }
-
// return tangled.RepoPullComment{
-
// Pull: p.PullAt,
-
// Body: p.Body,
-
// Mentions: mentions,
-
// References: references,
-
// CreatedAt: p.Created.Format(time.RFC3339),
-
// }
-
// }
func (p *Pull) LastRoundNumber() int {
return len(p.Submissions) - 1
-49
appview/models/reference.go
···
-
package models
-
-
import "fmt"
-
-
type RefKind int
-
-
const (
-
RefKindIssue RefKind = iota
-
RefKindPull
-
)
-
-
func (k RefKind) String() string {
-
if k == RefKindIssue {
-
return "issues"
-
} else {
-
return "pulls"
-
}
-
}
-
-
// /@alice.com/cool-proj/issues/123
-
// /@alice.com/cool-proj/issues/123#comment-321
-
type ReferenceLink struct {
-
Handle string
-
Repo string
-
Kind RefKind
-
SubjectId int
-
CommentId *int
-
}
-
-
func (l ReferenceLink) String() string {
-
comment := ""
-
if l.CommentId != nil {
-
comment = fmt.Sprintf("#comment-%d", *l.CommentId)
-
}
-
return fmt.Sprintf("/%s/%s/%s/%d%s",
-
l.Handle,
-
l.Repo,
-
l.Kind.String(),
-
l.SubjectId,
-
comment,
-
)
-
}
-
-
type RichReferenceLink struct {
-
ReferenceLink
-
Title string
-
// reusing PullState for both issue & PR
-
State PullState
-
}
+47
appview/models/repo.go
···
Repo *Repo
Issues []Issue
}
+
+
type BlobContentType int
+
+
const (
+
BlobContentTypeCode BlobContentType = iota
+
BlobContentTypeMarkup
+
BlobContentTypeImage
+
BlobContentTypeSvg
+
BlobContentTypeVideo
+
BlobContentTypeSubmodule
+
)
+
+
func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode }
+
func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup }
+
func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage }
+
func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg }
+
func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo }
+
func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule }
+
+
type BlobView struct {
+
HasTextView bool // can show as code/text
+
HasRenderedView bool // can show rendered (markup/image/video/submodule)
+
HasRawView bool // can download raw (everything except submodule)
+
+
// current display mode
+
ShowingRendered bool // currently in rendered mode
+
ShowingText bool // currently in text/code mode
+
+
// content type flags
+
ContentType BlobContentType
+
+
// Content data
+
Contents string
+
ContentSrc string // URL for media files
+
Lines int
+
SizeHint uint64
+
}
+
+
// if both views are available, then show a toggle between them
+
func (b BlobView) ShowToggle() bool {
+
return b.HasTextView && b.HasRenderedView
+
}
+
+
func (b BlobView) IsUnsupported() bool {
+
// no view available, only raw
+
return !(b.HasRenderedView || b.HasTextView)
+
}
+43 -1
appview/pages/funcmap.go
···
package pages
import (
+
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
···
"strings"
"time"
+
"github.com/alecthomas/chroma/v2"
+
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+
"github.com/alecthomas/chroma/v2/lexers"
+
"github.com/alecthomas/chroma/v2/styles"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/dustin/go-humanize"
"github.com/go-enry/go-enry/v2"
+
"github.com/yuin/goldmark"
"tangled.org/core/appview/filetree"
"tangled.org/core/appview/pages/markup"
"tangled.org/core/crypto"
···
},
"description": func(text string) template.HTML {
p.rctx.RendererType = markup.RendererTypeDefault
-
htmlString := p.rctx.RenderMarkdown(text)
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
sanitized := p.rctx.SanitizeDescription(htmlString)
return template.HTML(sanitized)
+
},
+
"readme": func(text string) template.HTML {
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
+
htmlString := p.rctx.RenderMarkdown(text)
+
sanitized := p.rctx.SanitizeDefault(htmlString)
+
return template.HTML(sanitized)
+
},
+
"code": func(content, path string) string {
+
var style *chroma.Style = styles.Get("catpuccin-latte")
+
formatter := chromahtml.New(
+
chromahtml.InlineCode(false),
+
chromahtml.WithLineNumbers(true),
+
chromahtml.WithLinkableLineNumbers(true, "L"),
+
chromahtml.Standalone(false),
+
chromahtml.WithClasses(true),
+
)
+
+
lexer := lexers.Get(filepath.Base(path))
+
if lexer == nil {
+
lexer = lexers.Fallback
+
}
+
+
iterator, err := lexer.Tokenise(nil, content)
+
if err != nil {
+
p.logger.Error("chroma tokenize", "err", "err")
+
return ""
+
}
+
+
var code bytes.Buffer
+
err = formatter.Format(&code, style, iterator)
+
if err != nil {
+
p.logger.Error("chroma format", "err", "err")
+
return ""
+
}
+
+
return code.String()
},
"trimUriScheme": func(text string) string {
text = strings.TrimPrefix(text, "https://")
+1 -1
appview/pages/markup/extension/atlink.go
···
if entering {
w.WriteString(`<a href="/@`)
w.WriteString(n.(*AtNode).Handle)
-
w.WriteString(`" class="mention">`)
+
w.WriteString(`" class="mention font-bold">`)
} else {
w.WriteString("</a>")
}
+28 -2
appview/pages/markup/markdown.go
···
}
func (rctx *RenderContext) RenderMarkdown(source string) string {
-
md := NewMarkdown()
+
return rctx.RenderMarkdownWith(source, NewMarkdown())
+
}
+
func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string {
if rctx != nil {
var transformers []util.PrioritizedValue
···
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
-
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
+
url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath)
parsedURL := &url.URL{
Scheme: scheme,
···
}
return path.Join(rctx.CurrentDir, dst)
+
}
+
+
// FindUserMentions returns Set of user handles from given markup soruce.
+
// It doesn't guarntee unique DIDs
+
func FindUserMentions(source string) []string {
+
var (
+
mentions []string
+
mentionsSet = make(map[string]struct{})
+
md = NewMarkdown()
+
sourceBytes = []byte(source)
+
root = md.Parser().Parse(text.NewReader(sourceBytes))
+
)
+
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+
if entering && n.Kind() == textension.KindAt {
+
handle := n.(*textension.AtNode).Handle
+
mentionsSet[handle] = struct{}{}
+
return ast.WalkSkipChildren, nil
+
}
+
return ast.WalkContinue, nil
+
})
+
for handle := range mentionsSet {
+
mentions = append(mentions, handle)
+
}
+
return mentions
}
func isAbsoluteUrl(link string) bool {
-124
appview/pages/markup/reference_link.go
···
-
package markup
-
-
import (
-
"maps"
-
"net/url"
-
"path"
-
"slices"
-
"strconv"
-
"strings"
-
-
"github.com/yuin/goldmark/ast"
-
"github.com/yuin/goldmark/text"
-
"tangled.org/core/appview/models"
-
textension "tangled.org/core/appview/pages/markup/extension"
-
)
-
-
// FindReferences collects all links referencing tangled-related objects
-
// like issues, PRs, comments or even @-mentions
-
// This funciton doesn't actually check for the existence of records in the DB
-
// or the PDS; it merely returns a list of what are presumed to be references.
-
func FindReferences(baseUrl string, source string) ([]string, []models.ReferenceLink) {
-
var (
-
refLinkSet = make(map[models.ReferenceLink]struct{})
-
mentionsSet = make(map[string]struct{})
-
md = NewMarkdown()
-
sourceBytes = []byte(source)
-
root = md.Parser().Parse(text.NewReader(sourceBytes))
-
)
-
// trim url scheme. the SSL shouldn't matter
-
baseUrl = strings.TrimPrefix(baseUrl, "https://")
-
baseUrl = strings.TrimPrefix(baseUrl, "http://")
-
-
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
-
if !entering {
-
return ast.WalkContinue, nil
-
}
-
switch n.Kind() {
-
case textension.KindAt:
-
handle := n.(*textension.AtNode).Handle
-
mentionsSet[handle] = struct{}{}
-
return ast.WalkSkipChildren, nil
-
case ast.KindLink:
-
dest := string(n.(*ast.Link).Destination)
-
ref := parseTangledLink(baseUrl, dest)
-
if ref != nil {
-
refLinkSet[*ref] = struct{}{}
-
}
-
return ast.WalkSkipChildren, nil
-
case ast.KindAutoLink:
-
an := n.(*ast.AutoLink)
-
if an.AutoLinkType == ast.AutoLinkURL {
-
dest := string(an.URL(sourceBytes))
-
ref := parseTangledLink(baseUrl, dest)
-
if ref != nil {
-
refLinkSet[*ref] = struct{}{}
-
}
-
}
-
return ast.WalkSkipChildren, nil
-
}
-
return ast.WalkContinue, nil
-
})
-
mentions := slices.Collect(maps.Keys(mentionsSet))
-
references := slices.Collect(maps.Keys(refLinkSet))
-
return mentions, references
-
}
-
-
func parseTangledLink(baseHost string, urlStr string) *models.ReferenceLink {
-
u, err := url.Parse(urlStr)
-
if err != nil {
-
return nil
-
}
-
-
if u.Host != "" && !strings.EqualFold(u.Host, baseHost) {
-
return nil
-
}
-
-
p := path.Clean(u.Path)
-
parts := strings.FieldsFunc(p, func(r rune) bool { return r == '/' })
-
if len(parts) < 4 {
-
// need at least: handle / repo / kind / id
-
return nil
-
}
-
-
var (
-
handle = parts[0]
-
repo = parts[1]
-
kindSeg = parts[2]
-
subjectSeg = parts[3]
-
)
-
-
handle = strings.TrimPrefix(handle, "@")
-
-
var kind models.RefKind
-
switch kindSeg {
-
case "issues":
-
kind = models.RefKindIssue
-
case "pulls":
-
kind = models.RefKindPull
-
default:
-
return nil
-
}
-
-
subjectId, err := strconv.Atoi(subjectSeg)
-
if err != nil {
-
return nil
-
}
-
var commentId *int
-
if u.Fragment != "" {
-
if strings.HasPrefix(u.Fragment, "comment-") {
-
commentIdStr := u.Fragment[len("comment-"):]
-
if id, err := strconv.Atoi(commentIdStr); err == nil {
-
commentId = &id
-
}
-
}
-
}
-
-
return &models.ReferenceLink{
-
Handle: handle,
-
Repo: repo,
-
Kind: kind,
-
SubjectId: subjectId,
-
CommentId: commentId,
-
}
-
}
-42
appview/pages/markup/reference_link_test.go
···
-
package markup_test
-
-
import (
-
"testing"
-
-
"github.com/stretchr/testify/assert"
-
"tangled.org/core/appview/models"
-
"tangled.org/core/appview/pages/markup"
-
)
-
-
func TestMarkupParsing(t *testing.T) {
-
tests := []struct {
-
name string
-
source string
-
wantHandles []string
-
wantRefLinks []models.ReferenceLink
-
}{
-
{
-
name: "normal link",
-
source: `[link](http://127.0.0.1:3000/alice.pds.tngl.boltless.dev/coolproj/issues/1)`,
-
wantHandles: make([]string, 0),
-
wantRefLinks: []models.ReferenceLink{
-
{Handle: "alice.pds.tngl.boltless.dev", Repo: "coolproj", Kind: models.RefKindIssue, SubjectId: 1, CommentId: nil},
-
},
-
},
-
{
-
name: "commonmark style autolink",
-
source: `<http://127.0.0.1:3000/alice.pds.tngl.boltless.dev/coolproj/issues/1>`,
-
wantHandles: make([]string, 0),
-
wantRefLinks: []models.ReferenceLink{
-
{Handle: "alice.pds.tngl.boltless.dev", Repo: "coolproj", Kind: models.RefKindIssue, SubjectId: 1, CommentId: nil},
-
},
-
},
-
}
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
handles, refLinks := markup.FindReferences("http://127.0.0.1:3000", tt.source)
-
assert.ElementsMatch(t, tt.wantHandles, handles)
-
assert.ElementsMatch(t, tt.wantRefLinks, refLinks)
-
})
-
}
-
}
+10 -100
appview/pages/pages.go
···
package pages
import (
-
"bytes"
"crypto/sha256"
"embed"
"encoding/hex"
···
"tangled.org/core/patchutil"
"tangled.org/core/types"
-
"github.com/alecthomas/chroma/v2"
-
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
-
"github.com/alecthomas/chroma/v2/lexers"
-
"github.com/alecthomas/chroma/v2/styles"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/go-git/go-git/v5/plumbing"
···
func (r RepoTreeParams) TreeStats() RepoTreeStats {
numFolders, numFiles := 0, 0
for _, f := range r.Files {
-
if !f.IsFile {
+
if !f.IsFile() {
numFolders += 1
-
} else if f.IsFile {
+
} else if f.IsFile() {
numFiles += 1
}
}
···
}
type RepoBlobParams struct {
-
LoggedInUser *oauth.User
-
RepoInfo repoinfo.RepoInfo
-
Active string
-
Unsupported bool
-
IsImage bool
-
IsVideo bool
-
ContentSrc string
-
BreadCrumbs [][]string
-
ShowRendered bool
-
RenderToggle bool
-
RenderedContents template.HTML
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Active string
+
BreadCrumbs [][]string
+
BlobView models.BlobView
*tangled.RepoBlob_Output
-
// Computed fields for template compatibility
-
Contents string
-
Lines int
-
SizeHint uint64
-
IsBinary bool
}
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
-
var style *chroma.Style = styles.Get("catpuccin-latte")
-
-
if params.ShowRendered {
-
switch markup.GetFormat(params.Path) {
-
case markup.FormatMarkdown:
-
p.rctx.RepoInfo = params.RepoInfo
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
-
htmlString := p.rctx.RenderMarkdown(params.Contents)
-
sanitized := p.rctx.SanitizeDefault(htmlString)
-
params.RenderedContents = template.HTML(sanitized)
-
}
-
}
-
-
c := params.Contents
-
formatter := chromahtml.New(
-
chromahtml.InlineCode(false),
-
chromahtml.WithLineNumbers(true),
-
chromahtml.WithLinkableLineNumbers(true, "L"),
-
chromahtml.Standalone(false),
-
chromahtml.WithClasses(true),
-
)
-
-
lexer := lexers.Get(filepath.Base(params.Path))
-
if lexer == nil {
-
lexer = lexers.Fallback
-
}
-
-
iterator, err := lexer.Tokenise(nil, c)
-
if err != nil {
-
return fmt.Errorf("chroma tokenize: %w", err)
+
switch params.BlobView.ContentType {
+
case models.BlobContentTypeMarkup:
+
p.rctx.RepoInfo = params.RepoInfo
}
-
var code bytes.Buffer
-
err = formatter.Format(&code, style, iterator)
-
if err != nil {
-
return fmt.Errorf("chroma format: %w", err)
-
}
-
-
params.Contents = code.String()
params.Active = "overview"
return p.executeRepo("repo/blob", w, params)
}
···
Active string
Issue *models.Issue
CommentList []models.CommentListItem
-
Backlinks []models.RichReferenceLink
LabelDefs map[string]*models.LabelDefinition
OrderedReactionKinds []models.ReactionKind
···
Pull *models.Pull
Stack models.Stack
AbandonedPulls []*models.Pull
-
Backlinks []models.RichReferenceLink
BranchDeleteStatus *models.BranchDeleteStatus
MergeCheck types.MergeCheckResponse
ResubmitCheck ResubmitResult
···
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
-
var style *chroma.Style = styles.Get("catpuccin-latte")
-
-
if params.ShowRendered {
-
switch markup.GetFormat(params.String.Filename) {
-
case markup.FormatMarkdown:
-
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
-
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
-
sanitized := p.rctx.SanitizeDefault(htmlString)
-
params.RenderedContents = template.HTML(sanitized)
-
}
-
}
-
-
c := params.String.Contents
-
formatter := chromahtml.New(
-
chromahtml.InlineCode(false),
-
chromahtml.WithLineNumbers(true),
-
chromahtml.WithLinkableLineNumbers(true, "L"),
-
chromahtml.Standalone(false),
-
chromahtml.WithClasses(true),
-
)
-
-
lexer := lexers.Get(filepath.Base(params.String.Filename))
-
if lexer == nil {
-
lexer = lexers.Fallback
-
}
-
-
iterator, err := lexer.Tokenise(nil, c)
-
if err != nil {
-
return fmt.Errorf("chroma tokenize: %w", err)
-
}
-
-
var code bytes.Buffer
-
err = formatter.Format(&code, style, iterator)
-
if err != nil {
-
return fmt.Errorf("chroma format: %w", err)
-
}
-
-
params.String.Contents = code.String()
return p.execute("strings/string", w, params)
+17 -12
appview/pages/templates/knots/fragments/addMemberModal.html
···
<div
id="add-member-{{ .Id }}"
popover
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
+
class="
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
{{ block "addKnotMemberPopover" . }} {{ end }}
</div>
{{ end }}
···
ADD MEMBER
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
-
<input
-
autocapitalize="none"
-
autocorrect="off"
-
autocomplete="off"
-
type="text"
-
id="member-did-{{ .Id }}"
-
name="member"
-
required
-
placeholder="foo.bsky.social"
-
/>
+
<actor-typeahead>
+
<input
+
autocapitalize="none"
+
autocorrect="off"
+
autocomplete="off"
+
type="text"
+
id="member-did-{{ .Id }}"
+
name="member"
+
required
+
placeholder="user.tngl.sh"
+
class="w-full"
+
/>
+
</actor-typeahead>
<div class="flex gap-2 pt-2">
<button
type="button"
···
</div>
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
</form>
-
{{ end }}
+
{{ end }}
+1
appview/pages/templates/layouts/base.html
···
<script defer src="/static/htmx.min.js"></script>
<script defer src="/static/htmx-ext-ws.min.js"></script>
+
<script defer src="/static/actor-typeahead.js" type="module"></script>
<!-- preconnect to image cdn -->
<link rel="preconnect" href="https://avatar.tangled.sh" />
+64 -39
appview/pages/templates/repo/blob.html
···
{{ end }}
{{ define "repoContent" }}
-
{{ $lines := split .Contents }}
-
{{ $tot_lines := len $lines }}
-
{{ $tot_chars := len (printf "%d" $tot_lines) }}
-
{{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }}
{{ $linkstyle := "no-underline hover:underline" }}
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
<div class="flex flex-col md:flex-row md:justify-between gap-2">
···
</div>
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
-
<span>{{ .Lines }} lines</span>
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
-
<span>{{ byteFmt .SizeHint }}</span>
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
-
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
-
{{ if .RenderToggle }}
-
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
-
<a
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
-
hx-boost="true"
-
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
+
+
{{ if .BlobView.ShowingText }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<span>{{ .Lines }} lines</span>
+
{{ end }}
+
+
{{ if .BlobView.SizeHint }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<span>{{ byteFmt .BlobView.SizeHint }}</span>
+
{{ end }}
+
+
{{ if .BlobView.HasRawView }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
+
{{ end }}
+
+
{{ if .BlobView.ShowToggle }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
+
<a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true">
+
view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }}
+
</a>
{{ end }}
</div>
</div>
</div>
-
{{ if and .IsBinary .Unsupported }}
-
<p class="text-center text-gray-400 dark:text-gray-500">
-
Previews are not supported for this file type.
-
</p>
-
{{ else if .IsBinary }}
-
<div class="text-center">
-
{{ if .IsImage }}
-
<img src="{{ .ContentSrc }}"
-
alt="{{ .Path }}"
-
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
-
{{ else if .IsVideo }}
-
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
-
<source src="{{ .ContentSrc }}">
-
Your browser does not support the video tag.
-
</video>
-
{{ end }}
-
</div>
-
{{ else }}
-
<div class="overflow-auto relative">
-
{{ if .ShowRendered }}
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
+
{{ if .BlobView.IsUnsupported }}
+
<p class="text-center text-gray-400 dark:text-gray-500">
+
Previews are not supported for this file type.
+
</p>
+
{{ else if .BlobView.ContentType.IsSubmodule }}
+
<p class="text-center text-gray-400 dark:text-gray-500">
+
This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>.
+
</p>
+
{{ else if .BlobView.ContentType.IsImage }}
+
<div class="text-center">
+
<img src="{{ .BlobView.ContentSrc }}"
+
alt="{{ .Path }}"
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
+
</div>
+
{{ else if .BlobView.ContentType.IsVideo }}
+
<div class="text-center">
+
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
+
<source src="{{ .BlobView.ContentSrc }}">
+
Your browser does not support the video tag.
+
</video>
+
</div>
+
{{ else if .BlobView.ContentType.IsSvg }}
+
<div class="overflow-auto relative">
+
{{ if .BlobView.ShowingRendered }}
+
<div class="text-center">
+
<img src="{{ .BlobView.ContentSrc }}"
+
alt="{{ .Path }}"
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
+
</div>
+
{{ else }}
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
+
{{ end }}
+
</div>
+
{{ else if .BlobView.ContentType.IsMarkup }}
+
<div class="overflow-auto relative">
+
{{ if .BlobView.ShowingRendered }}
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div>
{{ else }}
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
{{ end }}
-
</div>
+
</div>
+
{{ else if .BlobView.ContentType.IsCode }}
+
<div class="overflow-auto relative">
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div>
+
</div>
{{ end }}
{{ template "fragments/multiline-select" }}
{{ end }}
+1 -1
appview/pages/templates/repo/compare/compare.html
···
{{ end }}
{{ define "mainLayout" }}
-
<div class="px-1 col-span-full flex flex-col gap-4">
+
<div class="px-1 flex-grow col-span-full flex flex-col gap-4">
{{ block "contentLayout" . }}
{{ block "content" . }}{{ end }}
{{ end }}
-49
appview/pages/templates/repo/fragments/backlinks.html
···
-
{{ define "repo/fragments/backlinks" }}
-
{{ if .Backlinks }}
-
<div id="at-uri-panel" class="px-2 md:px-0">
-
<div>
-
<span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400">Referenced by</span>
-
</div>
-
<ul>
-
{{ range .Backlinks }}
-
<li>
-
{{ $repoOwner := resolve .Handle }}
-
{{ $repoName := .Repo }}
-
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
-
<div class="flex flex-col">
-
<div class="flex gap-2 items-center">
-
{{ if .State.IsClosed }}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "ban" "w-4 h-4" }}
-
</span>
-
{{ else if eq .Kind.String "issues" }}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "circle-dot" "w-4 h-4" }}
-
</span>
-
{{ else if .State.IsOpen }}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "git-pull-request" "w-4 h-4" }}
-
</span>
-
{{ else if .State.IsMerged }}
-
<span class="text-purple-600 dark:text-purple-500">
-
{{ i "git-merge" "w-4 h-4" }}
-
</span>
-
{{ else }}
-
<span class="text-gray-600 dark:text-gray-300">
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
-
</span>
-
{{ end }}
-
<a href="{{ . }}"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a>
-
</div>
-
{{ if not (eq $.RepoInfo.FullName $repoUrl) }}
-
<div>
-
<span>on <a href="/{{ $repoUrl }}">{{ $repoUrl }}</a></span>
-
</div>
-
{{ end }}
-
</div>
-
</li>
-
{{ end }}
-
</ul>
-
</div>
-
{{ end }}
-
{{ end }}
+8 -1
appview/pages/templates/repo/index.html
···
{{ end }}
{{ define "repoLanguages" }}
-
<details class="group -m-6 mb-4">
+
<details class="group -my-4 -m-6 mb-4">
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
{{ range $value := .Languages }}
<div
···
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
+
{{ if .IsSubmodule }}
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
+
{{ $icon = "folder-input" }}
+
{{ $iconStyle = "size-4" }}
+
{{ end }}
+
{{ if .IsFile }}
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
{{ $icon = "file" }}
{{ $iconStyle = "size-4" }}
{{ end }}
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
<div class="flex items-center gap-2">
{{ i $icon $iconStyle "flex-shrink-0" }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
{{ end }}
{{ define "timestamp" }}
-
<a href="#comment-{{ .Comment.Id }}"
+
<a href="#{{ .Comment.Id }}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
-
id="comment-{{ .Comment.Id }}">
+
id="{{ .Comment.Id }}">
{{ if .Comment.Deleted }}
{{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }}
{{ else if .Comment.Edited }}
-3
appview/pages/templates/repo/issues/issue.html
···
"Subject" $.Issue.AtUri
"State" $.Issue.Labels) }}
{{ template "repo/fragments/participants" $.Issue.Participants }}
-
{{ template "repo/fragments/backlinks"
-
(dict "RepoInfo" $.RepoInfo
-
"Backlinks" $.Backlinks) }}
{{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }}
</div>
</div>
+19 -24
appview/pages/templates/repo/issues/issues.html
···
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
{{ $values := list $open $closed }}
-
<div class="flex flex-col gap-2">
-
<div class="flex justify-between items-stretch gap-4">
-
<form class="flex flex-1 relative" method="GET">
-
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
-
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
-
{{ i "search" "w-4 h-4" }}
-
</div>
-
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
-
<a
-
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
-
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
-
>
-
{{ i "x" "w-4 h-4" }}
-
</a>
-
</form>
-
<div class="hidden sm:block">
-
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
+
<input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}">
+
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
+
{{ i "search" "w-4 h-4" }}
</div>
+
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
<a
-
href="/{{ .RepoInfo.FullName }}/issues/new"
-
class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
-
>
-
{{ i "circle-plus" "w-4 h-4" }}
-
<span>new</span>
+
href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
+
>
+
{{ i "x" "w-4 h-4" }}
</a>
-
</div>
-
<div class="sm:hidden">
+
</form>
+
<div class="sm:row-start-1">
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
</div>
+
<a
+
href="/{{ .RepoInfo.FullName }}/issues/new"
+
class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white"
+
>
+
{{ i "circle-plus" "w-4 h-4" }}
+
<span>new</span>
+
</a>
</div>
<div class="error" id="issues"></div>
{{ end }}
+3 -3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
<div id="lines" hx-swap-oob="beforeend">
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700">
<summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400">
-
<div class="group-open:hidden flex items-center gap-1">{{ template "stepHeader" . }}</div>
-
<div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</div>
+
<div class="group-open:hidden flex items-center gap-1">{{ i "chevron-right" "w-4 h-4" }} {{ template "stepHeader" . }}</div>
+
<div class="hidden group-open:flex items-center gap-1">{{ i "chevron-down" "w-4 h-4" }} {{ template "stepHeader" . }}</div>
</summary>
<div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div>
</details>
···
{{ end }}
{{ define "stepHeader" }}
-
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
+
{{ .Name }}
<span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span>
{{ end }}
+81 -83
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
{{ $isLastRound := eq $roundNumber $lastIdx }}
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
{{ $isUpToDate := .ResubmitCheck.No }}
-
<div class="relative w-fit">
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
-
<button
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
-
hx-target="#actions-{{$roundNumber}}"
-
hx-swap="outerHtml"
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
-
{{ i "message-square-plus" "w-4 h-4" }}
-
<span>comment</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ if .BranchDeleteStatus }}
-
<button
-
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
-
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
-
hx-swap="none"
-
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">
-
{{ i "git-branch" "w-4 h-4" }}
-
<span>delete branch</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
-
{{ $disabled := "" }}
-
{{ if $isConflicted }}
-
{{ $disabled = "disabled" }}
-
{{ end }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
-
hx-swap="none"
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
-
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
-
{{ i "git-merge" "w-4 h-4" }}
-
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative">
+
<button
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group">
+
{{ i "message-square-plus" "w-4 h-4" }}
+
<span>comment</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ if .BranchDeleteStatus }}
+
<button
+
hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches"
+
hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }'
+
hx-swap="none"
+
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">
+
{{ i "git-branch" "w-4 h-4" }}
+
<span>delete branch</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
+
{{ $disabled := "" }}
+
{{ if $isConflicted }}
+
{{ $disabled = "disabled" }}
+
{{ end }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
+
hx-swap="none"
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
+
class="btn p-2 flex items-center gap-2 group" {{ $disabled }}>
+
{{ i "git-merge" "w-4 h-4" }}
+
<span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
-
{{ $disabled := "" }}
-
{{ if $isUpToDate }}
-
{{ $disabled = "disabled" }}
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
+
{{ $disabled := "" }}
+
{{ if $isUpToDate }}
+
{{ $disabled = "disabled" }}
+
{{ end }}
+
<button id="resubmitBtn"
+
{{ if not .Pull.IsPatchBased }}
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
{{ else }}
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
{{ end }}
-
<button id="resubmitBtn"
-
{{ if not .Pull.IsPatchBased }}
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
-
{{ else }}
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
-
hx-target="#actions-{{$roundNumber}}"
-
hx-swap="outerHtml"
-
{{ end }}
-
hx-disabled-elt="#resubmitBtn"
-
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
+
hx-disabled-elt="#resubmitBtn"
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }}
-
{{ if $disabled }}
-
title="Update this branch to resubmit this pull request"
-
{{ else }}
-
title="Resubmit this pull request"
-
{{ end }}
-
>
-
{{ i "rotate-ccw" "w-4 h-4" }}
-
<span>resubmit</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
+
{{ if $disabled }}
+
title="Update this branch to resubmit this pull request"
+
{{ else }}
+
title="Resubmit this pull request"
+
{{ end }}
+
>
+
{{ i "rotate-ccw" "w-4 h-4" }}
+
<span>resubmit</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
-
hx-swap="none"
-
class="btn p-2 flex items-center gap-2 group">
-
{{ i "ban" "w-4 h-4" }}
-
<span>close</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
+
hx-swap="none"
+
class="btn p-2 flex items-center gap-2 group">
+
{{ i "ban" "w-4 h-4" }}
+
<span>close</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
-
hx-swap="none"
-
class="btn p-2 flex items-center gap-2 group">
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
-
<span>reopen</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
-
</div>
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
+
hx-swap="none"
+
class="btn p-2 flex items-center gap-2 group">
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
+
<span>reopen</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
</div>
{{ end }}
-3
appview/pages/templates/repo/pulls/pull.html
···
"Subject" $.Pull.AtUri
"State" $.Pull.Labels) }}
{{ template "repo/fragments/participants" $.Pull.Participants }}
-
{{ template "repo/fragments/backlinks"
-
(dict "RepoInfo" $.RepoInfo
-
"Backlinks" $.Backlinks) }}
{{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }}
</div>
</div>
+20 -24
appview/pages/templates/repo/pulls/pulls.html
···
"Key" "closed"
"Value" "closed"
"Icon" "ban"
-
"Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }}
+
"Meta" (string .RepoInfo.Stats.PullCount.Closed)) }}
{{ $values := list $open $merged $closed }}
-
<div class="flex flex-col gap-2">
-
<div class="flex justify-between items-stretch gap-2">
-
<form class="flex flex-1 relative" method="GET">
-
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
-
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
-
{{ i "search" "w-4 h-4" }}
-
</div>
-
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
-
<a
-
href="?state={{ .FilteringBy.String }}"
-
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
-
>
-
{{ i "x" "w-4 h-4" }}
-
</a>
-
</form>
-
<div class="hidden sm:block">
-
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
+
<div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2">
+
<form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET">
+
<input type="hidden" name="state" value="{{ .FilteringBy.String }}">
+
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
+
{{ i "search" "w-4 h-4" }}
</div>
-
<a href="/{{ .RepoInfo.FullName }}/pulls/new"
-
class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
+
<input class="flex-1 p-1 pl-10 pr-10 peer" type="text" name="q" value="{{ .FilterQuery }}" placeholder=" ">
+
<a
+
href="?state={{ .FilteringBy.String }}"
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block"
>
-
{{ i "git-pull-request-create" "w-4 h-4" }}
-
<span>new</span>
+
{{ i "x" "w-4 h-4" }}
</a>
-
</div>
-
<div class="sm:hidden">
+
</form>
+
<div class="sm:row-start-1">
{{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }}
</div>
+
<a
+
href="/{{ .RepoInfo.FullName }}/pulls/new"
+
class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white"
+
>
+
{{ i "git-pull-request-create" "w-4 h-4" }}
+
<span>new</span>
+
</a>
</div>
<div class="error" id="pulls"></div>
{{ end }}
+17 -10
appview/pages/templates/repo/settings/access.html
···
<div
id="add-collaborator-modal"
popover
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
+
class="
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700
+
dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
{{ template "addCollaboratorModal" . }}
</div>
{{ end }}
···
ADD COLLABORATOR
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
-
<input
-
autocapitalize="none"
-
autocorrect="off"
-
type="text"
-
id="add-collaborator"
-
name="collaborator"
-
required
-
placeholder="foo.bsky.social"
-
/>
+
<actor-typeahead>
+
<input
+
autocapitalize="none"
+
autocorrect="off"
+
autocomplete="off"
+
type="text"
+
id="add-collaborator"
+
name="collaborator"
+
required
+
placeholder="user.tngl.sh"
+
class="w-full"
+
/>
+
</actor-typeahead>
<div class="flex gap-2 pt-2">
<button
type="button"
+1 -1
appview/pages/templates/repo/settings/general.html
···
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
</div>
-
<fieldset>
+
</fieldset>
</form>
{{ end }}
+8
appview/pages/templates/repo/tree.html
···
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
+
{{ if .IsSubmodule }}
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
+
{{ $icon = "folder-input" }}
+
{{ $iconStyle = "size-4" }}
+
{{ end }}
+
{{ if .IsFile }}
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
{{ $icon = "file" }}
{{ $iconStyle = "size-4" }}
{{ end }}
+
<a href="{{ $link }}" class="{{ $linkstyle }}">
<div class="flex items-center gap-2">
{{ i $icon $iconStyle "flex-shrink-0" }}
+16 -11
appview/pages/templates/spindles/fragments/addMemberModal.html
···
<div
id="add-member-{{ .Instance }}"
popover
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
+
class="
+
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50
+
w-full md:w-96 p-4 rounded drop-shadow overflow-visible">
{{ block "addSpindleMemberPopover" . }} {{ end }}
</div>
{{ end }}
···
ADD MEMBER
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
-
<input
-
autocapitalize="none"
-
autocorrect="off"
-
autocomplete="off"
-
type="text"
-
id="member-did-{{ .Id }}"
-
name="member"
-
required
-
placeholder="foo.bsky.social"
-
/>
+
<actor-typeahead>
+
<input
+
autocapitalize="none"
+
autocorrect="off"
+
autocomplete="off"
+
type="text"
+
id="member-did-{{ .Id }}"
+
name="member"
+
required
+
placeholder="user.tngl.sh"
+
class="w-full"
+
/>
+
</actor-typeahead>
<div class="flex gap-2 pt-2">
<button
type="button"
+2 -2
appview/pages/templates/strings/string.html
···
</div>
<div class="overflow-x-auto overflow-y-hidden relative">
{{ if .ShowRendered }}
-
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div>
{{ else }}
-
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div>
{{ end }}
</div>
{{ template "fragments/multiline-select" }}
+7 -1
appview/pages/templates/user/fragments/editBio.html
···
{{ if and .Profile .Profile.Pronouns }}
{{ $pronouns = .Profile.Pronouns }}
{{ end }}
-
<input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}">
+
<input
+
type="text"
+
class="py-1 px-1 w-full"
+
name="pronouns"
+
placeholder="they/them"
+
value="{{ $pronouns }}"
+
>
</div>
</div>
+3
appview/pipelines/pipelines.go
···
ps, err := db.GetPipelineStatuses(
p.db,
+
30,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
ps, err := db.GetPipelineStatuses(
p.db,
+
1,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
ps, err := db.GetPipelineStatuses(
p.db,
+
1,
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
+15 -28
appview/pulls/pulls.go
···
package pulls
import (
-
"context"
"database/sql"
"encoding/json"
"errors"
···
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/pages/markup"
-
"tangled.org/core/appview/refresolver"
"tangled.org/core/appview/reporesolver"
"tangled.org/core/appview/validator"
"tangled.org/core/appview/xrpcclient"
···
repoResolver *reporesolver.RepoResolver
pages *pages.Pages
idResolver *idresolver.Resolver
-
refResolver *refresolver.Resolver
db *db.DB
config *config.Config
notifier notify.Notifier
···
repoResolver *reporesolver.RepoResolver,
pages *pages.Pages,
resolver *idresolver.Resolver,
-
refResolver *refresolver.Resolver,
db *db.DB,
config *config.Config,
notifier notify.Notifier,
···
repoResolver: repoResolver,
pages: pages,
idResolver: resolver,
-
refResolver: refResolver,
db: db,
config: config,
notifier: notifier,
···
return
}
-
backlinks, err := db.GetBacklinks(s.db, pull.AtUri())
-
if err != nil {
-
log.Println("failed to get pull backlinks", err)
-
s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.")
-
return
-
}
-
// can be nil if this pull is not stacked
stack, _ := r.Context().Value("stack").(models.Stack)
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
···
ps, err := db.GetPipelineStatuses(
s.db,
+
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
Pull: pull,
Stack: stack,
AbandonedPulls: abandonedPulls,
-
Backlinks: backlinks,
BranchDeleteStatus: branchDeleteStatus,
MergeCheck: mergeCheckResponse,
ResubmitCheck: resubmitResult,
···
repoInfo := f.RepoInfo(user)
ps, err := db.GetPipelineStatuses(
s.db,
+
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
···
}
func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
+
l := s.logger.With("handler", "PullComment")
user := s.oauth.GetUser(r)
f, err := s.repoResolver.Resolve(r)
if err != nil {
···
s.pages.Notice(w, "pull", "Comment body is required")
return
}
-
-
mentions, references := s.refResolver.Resolve(r.Context(), body)
// Start a transaction
tx, err := s.db.BeginTx(r.Context(), nil)
···
Body: body,
CommentAt: atResp.Uri,
SubmissionId: pull.Submissions[roundNumber].ID,
-
Mentions: mentions,
-
References: references,
}
// Create the pull comment in the database with the commentAt field
···
return
}
+
rawMentions := markup.FindUserMentions(comment.Body)
+
idents := s.idResolver.ResolveIdents(r.Context(), rawMentions)
+
l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
+
var mentions []syntax.DID
+
for _, ident := range idents {
+
if ident != nil && !ident.Handle.IsInvalidHandle() {
+
mentions = append(mentions, ident.DID)
+
}
+
}
s.notifier.NewPullComment(r.Context(), comment, mentions)
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
···
-
mentions, references := s.refResolver.Resolve(r.Context(), body)
-
rkey := tid.TID()
initialSubmission := models.PullSubmission{
Patch: patch,
···
OwnerDid: user.Did,
RepoAt: f.RepoAt(),
Rkey: rkey,
-
Mentions: mentions,
-
References: references,
Submissions: []*models.PullSubmission{
&initialSubmission,
},
···
// build a stack out of this patch
stackId := uuid.New()
-
stack, err := s.newStack(r.Context(), f, user, targetBranch, patch, pullSource, stackId.String())
+
stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
if err != nil {
log.Println("failed to create stack", err)
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
···
targetBranch := pull.TargetBranch
origStack, _ := r.Context().Value("stack").(models.Stack)
-
newStack, err := s.newStack(r.Context(), f, user, targetBranch, patch, pull.PullSource, stackId)
+
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
if err != nil {
log.Println("failed to create resubmitted stack", err)
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
-
func (s *Pulls) newStack(ctx context.Context, f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
+
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
formatPatches, err := patchutil.ExtractPatches(patch)
if err != nil {
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
body := fp.Body
rkey := tid.TID()
-
mentions, references := s.refResolver.Resolve(ctx, body)
-
initialSubmission := models.PullSubmission{
Patch: fp.Raw,
SourceRev: fp.SHA,
···
OwnerDid: user.Did,
RepoAt: f.RepoAt(),
Rkey: rkey,
-
Mentions: mentions,
-
References: references,
Submissions: []*models.PullSubmission{
&initialSubmission,
},
-65
appview/refresolver/resolver.go
···
-
package refresolver
-
-
import (
-
"context"
-
"log/slog"
-
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
"tangled.org/core/appview/config"
-
"tangled.org/core/appview/db"
-
"tangled.org/core/appview/models"
-
"tangled.org/core/appview/pages/markup"
-
"tangled.org/core/idresolver"
-
)
-
-
type Resolver struct {
-
config *config.Config
-
idResolver *idresolver.Resolver
-
execer db.Execer
-
logger *slog.Logger
-
}
-
-
func New(
-
config *config.Config,
-
idResolver *idresolver.Resolver,
-
execer db.Execer,
-
logger *slog.Logger,
-
) *Resolver {
-
return &Resolver{
-
config,
-
idResolver,
-
execer,
-
logger,
-
}
-
}
-
-
func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) {
-
l := r.logger.With("method", "Resolve")
-
rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source)
-
l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs)
-
idents := r.idResolver.ResolveIdents(ctx, rawMentions)
-
var mentions []syntax.DID
-
for _, ident := range idents {
-
if ident != nil && !ident.Handle.IsInvalidHandle() {
-
mentions = append(mentions, ident.DID)
-
}
-
}
-
l.Debug("found mentions", "mentions", mentions)
-
-
var resolvedRefs []models.ReferenceLink
-
for _, rawRef := range rawRefs {
-
ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle)
-
if err != nil || ident == nil || ident.Handle.IsInvalidHandle() {
-
continue
-
}
-
rawRef.Handle = string(ident.DID)
-
resolvedRefs = append(resolvedRefs, rawRef)
-
}
-
aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs)
-
if err != nil {
-
l.Error("failed running query", "err", err)
-
}
-
l.Debug("found references", "refs", aturiRefs)
-
-
return mentions, aturiRefs
-
}
+136 -64
appview/repo/blob.go
···
package repo
import (
+
"encoding/base64"
"fmt"
"io"
"net/http"
···
"strings"
"tangled.org/core/api/tangled"
+
"tangled.org/core/appview/config"
+
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/pages/markup"
+
"tangled.org/core/appview/reporesolver"
xrpcclient "tangled.org/core/appview/xrpcclient"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
)
+
// the content can be one of the following:
+
//
+
// - code : text | | raw
+
// - markup : text | rendered | raw
+
// - svg : text | rendered | raw
+
// - png : | rendered | raw
+
// - video : | rendered | raw
+
// - submodule : | rendered |
+
// - rest : | |
func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
l := rp.logger.With("handler", "RepoBlob")
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
l.Error("failed to get repo and knot", "err", err)
return
}
+
ref := chi.URLParam(r, "ref")
ref, _ = url.PathUnescape(ref)
+
filePath := chi.URLParam(r, "*")
filePath, _ = url.PathUnescape(filePath)
+
scheme := "http"
if !rp.config.Core.Dev {
scheme = "https"
···
rp.pages.Error503(w)
return
}
+
// Use XRPC response directly instead of converting to internal types
var breadcrumbs [][]string
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
···
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
}
}
-
showRendered := false
-
renderToggle := false
-
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
-
renderToggle = true
-
showRendered = r.URL.Query().Get("code") != "true"
-
}
-
var unsupported bool
-
var isImage bool
-
var isVideo bool
-
var contentSrc string
-
if resp.IsBinary != nil && *resp.IsBinary {
-
ext := strings.ToLower(filepath.Ext(resp.Path))
-
switch ext {
-
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
-
isImage = true
-
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
-
isVideo = true
-
default:
-
unsupported = true
-
}
-
// fetch the raw binary content using sh.tangled.repo.blob xrpc
-
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
-
baseURL := &url.URL{
-
Scheme: scheme,
-
Host: f.Knot,
-
Path: "/xrpc/sh.tangled.repo.blob",
-
}
-
query := baseURL.Query()
-
query.Set("repo", repoName)
-
query.Set("ref", ref)
-
query.Set("path", filePath)
-
query.Set("raw", "true")
-
baseURL.RawQuery = query.Encode()
-
blobURL := baseURL.String()
-
contentSrc = blobURL
-
if !rp.config.Core.Dev {
-
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
-
}
-
}
-
lines := 0
-
if resp.IsBinary == nil || !*resp.IsBinary {
-
lines = strings.Count(resp.Content, "\n") + 1
-
}
-
var sizeHint uint64
-
if resp.Size != nil {
-
sizeHint = uint64(*resp.Size)
-
} else {
-
sizeHint = uint64(len(resp.Content))
-
}
+
+
// Create the blob view
+
blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
+
user := rp.oauth.GetUser(r)
-
// Determine if content is binary (dereference pointer)
-
isBinary := false
-
if resp.IsBinary != nil {
-
isBinary = *resp.IsBinary
-
}
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
BreadCrumbs: breadcrumbs,
-
ShowRendered: showRendered,
-
RenderToggle: renderToggle,
-
Unsupported: unsupported,
-
IsImage: isImage,
-
IsVideo: isVideo,
-
ContentSrc: contentSrc,
+
BlobView: blobView,
RepoBlob_Output: resp,
-
Contents: resp.Content,
-
Lines: lines,
-
SizeHint: sizeHint,
-
IsBinary: isBinary,
})
}
func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
l := rp.logger.With("handler", "RepoBlobRaw")
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
l.Error("failed to get repo and knot", "err", err)
w.WriteHeader(http.StatusBadRequest)
return
}
+
ref := chi.URLParam(r, "ref")
ref, _ = url.PathUnescape(ref)
+
filePath := chi.URLParam(r, "*")
filePath, _ = url.PathUnescape(filePath)
+
scheme := "http"
if !rp.config.Core.Dev {
scheme = "https"
···
l.Error("failed to create request", "err", err)
return
}
+
// forward the If-None-Match header
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
req.Header.Set("If-None-Match", clientETag)
}
client := &http.Client{}
+
resp, err := client.Do(req)
if err != nil {
l.Error("failed to reach knotserver", "err", err)
rp.pages.Error503(w)
return
}
+
defer resp.Body.Close()
+
// forward 304 not modified
if resp.StatusCode == http.StatusNotModified {
w.WriteHeader(http.StatusNotModified)
return
}
+
if resp.StatusCode != http.StatusOK {
l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
+
contentType := resp.Header.Get("Content-Type")
body, err := io.ReadAll(resp.Body)
if err != nil {
···
w.WriteHeader(http.StatusInternalServerError)
return
}
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
// serve all textual content as text/plain
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
···
w.Write([]byte("unsupported content type"))
return
}
+
}
+
+
// NewBlobView creates a BlobView from the XRPC response
+
func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView {
+
view := models.BlobView{
+
Contents: "",
+
Lines: 0,
+
}
+
+
// Set size
+
if resp.Size != nil {
+
view.SizeHint = uint64(*resp.Size)
+
} else if resp.Content != nil {
+
view.SizeHint = uint64(len(*resp.Content))
+
}
+
+
if resp.Submodule != nil {
+
view.ContentType = models.BlobContentTypeSubmodule
+
view.HasRenderedView = true
+
view.ContentSrc = resp.Submodule.Url
+
return view
+
}
+
+
// Determine if binary
+
if resp.IsBinary != nil && *resp.IsBinary {
+
view.ContentSrc = generateBlobURL(config, f, ref, filePath)
+
ext := strings.ToLower(filepath.Ext(resp.Path))
+
+
switch ext {
+
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
+
view.ContentType = models.BlobContentTypeImage
+
view.HasRawView = true
+
view.HasRenderedView = true
+
view.ShowingRendered = true
+
+
case ".svg":
+
view.ContentType = models.BlobContentTypeSvg
+
view.HasRawView = true
+
view.HasTextView = true
+
view.HasRenderedView = true
+
view.ShowingRendered = queryParams.Get("code") != "true"
+
if resp.Content != nil {
+
bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
+
view.Contents = string(bytes)
+
view.Lines = strings.Count(view.Contents, "\n") + 1
+
}
+
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
+
view.ContentType = models.BlobContentTypeVideo
+
view.HasRawView = true
+
view.HasRenderedView = true
+
view.ShowingRendered = true
+
}
+
+
return view
+
}
+
+
// otherwise, we are dealing with text content
+
view.HasRawView = true
+
view.HasTextView = true
+
+
if resp.Content != nil {
+
view.Contents = *resp.Content
+
view.Lines = strings.Count(view.Contents, "\n") + 1
+
}
+
+
// with text, we may be dealing with markdown
+
format := markup.GetFormat(resp.Path)
+
if format == markup.FormatMarkdown {
+
view.ContentType = models.BlobContentTypeMarkup
+
view.HasRenderedView = true
+
view.ShowingRendered = queryParams.Get("code") != "true"
+
}
+
+
return view
+
}
+
+
func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string {
+
scheme := "http"
+
if !config.Core.Dev {
+
scheme = "https"
+
}
+
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
baseURL := &url.URL{
+
Scheme: scheme,
+
Host: f.Knot,
+
Path: "/xrpc/sh.tangled.repo.blob",
+
}
+
query := baseURL.Query()
+
query.Set("repo", repoName)
+
query.Set("ref", ref)
+
query.Set("path", filePath)
+
query.Set("raw", "true")
+
baseURL.RawQuery = query.Encode()
+
blobURL := baseURL.String()
+
+
if !config.Core.Dev {
+
return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
+
}
+
return blobURL
}
func isTextualMimeType(mimeType string) bool {
+14 -10
appview/repo/compare.go
···
}
// if user is navigating to one of
-
// /compare/{base}/{head}
// /compare/{base}...{head}
-
base := chi.URLParam(r, "base")
-
head := chi.URLParam(r, "head")
-
if base == "" && head == "" {
-
rest := chi.URLParam(r, "*") // master...feature/xyz
-
parts := strings.SplitN(rest, "...", 2)
-
if len(parts) == 2 {
-
base = parts[0]
-
head = parts[1]
-
}
+
// /compare/{base}/{head}
+
var base, head string
+
rest := chi.URLParam(r, "*")
+
+
var parts []string
+
if strings.Contains(rest, "...") {
+
parts = strings.SplitN(rest, "...", 2)
+
} else if strings.Contains(rest, "/") {
+
parts = strings.SplitN(rest, "/", 2)
+
}
+
+
if len(parts) == 2 {
+
base = parts[0]
+
head = parts[1]
}
base, _ = url.PathUnescape(base)
+4 -5
appview/repo/index.go
···
if treeResp != nil && treeResp.Files != nil {
for _, file := range treeResp.Files {
niceFile := types.NiceTree{
-
IsFile: file.Is_file,
-
IsSubtree: file.Is_subtree,
-
Name: file.Name,
-
Mode: file.Mode,
-
Size: file.Size,
+
Name: file.Name,
+
Mode: file.Mode,
+
Size: file.Size,
}
+
if file.Last_commit != nil {
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
niceFile.LastCommit = &types.LastCommitInfo{
-2
appview/repo/repo.go
···
}
}
-
// isTextualMimeType returns true if the MIME type represents textual content
-
// modify the spindle configured for this repo
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
user := rp.oauth.GetUser(r)
+3 -16
appview/repo/repo_util.go
···
package repo
import (
-
"crypto/rand"
-
"math/big"
"slices"
"sort"
"strings"
···
func sortFiles(files []types.NiceTree) {
sort.Slice(files, func(i, j int) bool {
-
iIsFile := files[i].IsFile
-
jIsFile := files[j].IsFile
+
iIsFile := files[i].IsFile()
+
jIsFile := files[j].IsFile()
if iIsFile != jIsFile {
return !iIsFile
}
···
return
}
-
func randomString(n int) string {
-
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
-
result := make([]byte, n)
-
-
for i := 0; i < n; i++ {
-
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
-
result[i] = letters[n.Int64()]
-
}
-
-
return string(result)
-
}
-
// grab pipelines from DB and munge that into a hashmap with commit sha as key
//
// golang is so blessed that it requires 35 lines of imperative code for this
···
ps, err := db.GetPipelineStatuses(
d,
+
len(shas),
db.FilterEq("repo_owner", repoInfo.OwnerDid),
db.FilterEq("repo_name", repoInfo.Name),
db.FilterEq("knot", repoInfo.Knot),
-1
appview/repo/router.go
···
// for example:
// /compare/master...some/feature
// /compare/master...example.com:another/feature <- this is a fork
-
r.Get("/{base}/{head}", rp.Compare)
r.Get("/*", rp.Compare)
})
+1 -1
appview/repo/settings.go
···
)
err = rp.validator.ValidateURI(website)
-
if err != nil {
+
if website != "" && err != nil {
l.Error("invalid uri", "err", err)
rp.pages.Notice(w, noticeId, err.Error())
return
+4 -5
appview/repo/tree.go
···
files := make([]types.NiceTree, len(xrpcResp.Files))
for i, xrpcFile := range xrpcResp.Files {
file := types.NiceTree{
-
Name: xrpcFile.Name,
-
Mode: xrpcFile.Mode,
-
Size: int64(xrpcFile.Size),
-
IsFile: xrpcFile.Is_file,
-
IsSubtree: xrpcFile.Is_subtree,
+
Name: xrpcFile.Name,
+
Mode: xrpcFile.Mode,
+
Size: int64(xrpcFile.Size),
}
// Convert last commit info if present
if xrpcFile.Last_commit != nil {
···
}
}
sortFiles(result.Files)
+
rp.pages.RepoTree(w, pages.RepoTreeParams{
LoggedInUser: user,
BreadCrumbs: breadcrumbs,
+12 -11
appview/state/router.go
···
if userutil.IsFlattenedDid(firstPart) {
unflattenedDid := userutil.UnflattenDid(firstPart)
redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
+
+
redirectURL := *r.URL
+
redirectURL.Path = "/" + redirectPath
+
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
return
}
// if using a handle with @, rewrite to work without @
if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
-
http.Redirect(w, r, "/"+redirectPath, http.StatusFound)
+
+
redirectURL := *r.URL
+
redirectURL.Path = "/" + redirectPath
+
+
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
return
}
+
}
standardRouter.ServeHTTP(w, r)
···
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
r.Get("/", s.Profile)
r.Get("/feed.atom", s.AtomFeedPage)
-
-
// redirect /@handle/repo.git -> /@handle/repo
-
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
-
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
-
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
-
})
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
r.Use(mw.GoImport())
···
// r.Post("/import", s.ImportRepo)
})
-
r.Get("/goodfirstissues", s.GoodFirstIssues)
+
r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
r.Post("/", s.Follow)
···
s.repoResolver,
s.pages,
s.idResolver,
-
s.refResolver,
s.db,
s.config,
s.notifier,
···
s.repoResolver,
s.pages,
s.idResolver,
-
s.refResolver,
s.db,
s.config,
s.notifier,
-5
appview/state/state.go
···
phnotify "tangled.org/core/appview/notify/posthog"
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
-
"tangled.org/core/appview/refresolver"
"tangled.org/core/appview/reporesolver"
"tangled.org/core/appview/validator"
xrpcclient "tangled.org/core/appview/xrpcclient"
···
enforcer *rbac.Enforcer
pages *pages.Pages
idResolver *idresolver.Resolver
-
refResolver *refresolver.Resolver
posthog posthog.Client
jc *jetstream.JetstreamClient
config *config.Config
···
validator := validator.New(d, res, enforcer)
repoResolver := reporesolver.New(config, enforcer, res, d)
-
-
refResolver := refresolver.New(config, res, d, log.SubLogger(logger, "refResolver"))
wrapper := db.DbWrapper{Execer: d}
jc, err := jetstream.NewJetstreamClient(
···
enforcer,
pages,
res,
-
refResolver,
posthog,
jc,
config,
+17
flake.lock
···
{
"nodes": {
+
"actor-typeahead-src": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1762835797,
+
"narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=",
+
"ref": "refs/heads/main",
+
"rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b",
+
"revCount": 6,
+
"type": "git",
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
+
},
+
"original": {
+
"type": "git",
+
"url": "https://tangled.org/@jakelazaroff.com/actor-typeahead"
+
}
+
},
"flake-compat": {
"flake": false,
"locked": {
···
},
"root": {
"inputs": {
+
"actor-typeahead-src": "actor-typeahead-src",
"flake-compat": "flake-compat",
"gomod2nix": "gomod2nix",
"htmx-src": "htmx-src",
+12 -10
flake.nix
···
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
flake = false;
};
+
actor-typeahead-src = {
+
url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead";
+
flake = false;
+
};
ibm-plex-mono-src = {
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
flake = false;
···
inter-fonts-src,
sqlite-lib-src,
ibm-plex-mono-src,
+
actor-typeahead-src,
...
}: let
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
···
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;};
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
-
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
+
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src;
};
appview = self.callPackage ./nix/pkgs/appview.nix {};
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
···
air-watcher = name: arg:
pkgs.writeShellScriptBin "run"
''
-
${pkgs.air}/bin/air -c /dev/null \
-
-build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
-
-build.bin "./out/${name}.out" \
-
-build.args_bin "${arg}" \
-
-build.stop_on_error "true" \
-
-build.include_ext "go"
+
export PATH=${pkgs.go}/bin:$PATH
+
${pkgs.air}/bin/air -c ./.air/${name}.toml \
+
-build.args_bin "${arg}"
'';
tailwind-watcher =
pkgs.writeShellScriptBin "run"
···
}: {
imports = [./nix/modules/appview.nix];
-
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview;
+
services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview;
};
nixosModules.knot = {
lib,
···
}: {
imports = [./nix/modules/knot.nix];
-
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot;
+
services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot;
};
nixosModules.spindle = {
lib,
···
}: {
imports = [./nix/modules/spindle.nix];
-
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
+
services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle;
};
};
}
+4 -12
go.mod
···
github.com/alecthomas/assert/v2 v2.11.0
github.com/alecthomas/chroma/v2 v2.15.0
github.com/avast/retry-go/v4 v4.6.1
+
github.com/blevesearch/bleve/v2 v2.5.3
github.com/bluekeyes/go-gitdiff v0.8.1
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
+
github.com/bmatcuk/doublestar/v4 v4.9.1
github.com/carlmjohnson/versioninfo v0.22.5
github.com/casbin/casbin/v2 v2.103.0
+
github.com/charmbracelet/log v0.4.2
github.com/cloudflare/cloudflare-go v0.115.0
github.com/cyphar/filepath-securejoin v0.4.1
github.com/dgraph-io/ristretto v0.2.0
···
github.com/hiddeco/sshsig v0.2.0
github.com/hpcloud/tail v1.0.0
github.com/ipfs/go-cid v0.5.0
-
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/mattn/go-sqlite3 v1.14.24
github.com/microcosm-cc/bluemonday v1.0.27
github.com/openbao/openbao/api/v2 v2.3.0
···
github.com/wyatt915/goldmark-treeblood v0.0.1
github.com/yuin/goldmark v1.7.13
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
+
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
golang.org/x/crypto v0.40.0
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
golang.org/x/image v0.31.0
···
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
-
github.com/blevesearch/bleve/v2 v2.5.3 // indirect
github.com/blevesearch/bleve_index_api v1.2.8 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.25 // indirect
···
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
-
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/govaluate v1.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
-
github.com/charmbracelet/log v0.4.2 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
···
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
···
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
-
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
-
github.com/lestrrat-go/httpcc v1.0.1 // indirect
-
github.com/lestrrat-go/httprc v1.0.6 // indirect
-
github.com/lestrrat-go/iter v1.0.2 // indirect
-
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
···
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
-
github.com/segmentio/asm v1.2.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
···
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wyatt915/treeblood v0.1.16 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
-
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
-17
go.sum
···
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
-
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
···
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
-
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
···
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
-
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
-
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
-
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
-
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
-
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
-
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
-
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
-
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
-
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
-
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
-
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
···
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
-
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
-
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
+34
input.css
···
details[data-callout] > summary::-webkit-details-marker {
display: none;
}
+
}
@layer utilities {
.error {
···
text-decoration: underline;
}
}
+
+
actor-typeahead {
+
--color-background: #ffffff;
+
--color-border: #d1d5db;
+
--color-shadow: #000000;
+
--color-hover: #f9fafb;
+
--color-avatar-fallback: #e5e7eb;
+
--radius: 0.0;
+
--padding-menu: 0.0rem;
+
z-index: 1000;
+
}
+
+
actor-typeahead::part(handle) {
+
color: #111827;
+
}
+
+
actor-typeahead::part(menu) {
+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+
}
+
+
@media (prefers-color-scheme: dark) {
+
actor-typeahead {
+
--color-background: #1f2937;
+
--color-border: #4b5563;
+
--color-shadow: #000000;
+
--color-hover: #374151;
+
--color-avatar-fallback: #4b5563;
+
}
+
+
actor-typeahead::part(handle) {
+
color: #f9fafb;
+
}
+
}
+60 -2
knotserver/git/git.go
···
import (
"archive/tar"
"bytes"
+
"errors"
"fmt"
"io"
"io/fs"
···
"time"
"github.com/go-git/go-git/v5"
+
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
var (
-
ErrBinaryFile = fmt.Errorf("binary file")
-
ErrNotBinaryFile = fmt.Errorf("not binary file")
+
ErrBinaryFile = errors.New("binary file")
+
ErrNotBinaryFile = errors.New("not binary file")
+
ErrMissingGitModules = errors.New("no .gitmodules file found")
+
ErrInvalidGitModules = errors.New("invalid .gitmodules file")
+
ErrNotSubmodule = errors.New("path is not a submodule")
)
type GitRepo struct {
···
defer reader.Close()
return io.ReadAll(reader)
+
}
+
+
// read and parse .gitmodules
+
func (g *GitRepo) Submodules() (*config.Modules, error) {
+
c, err := g.r.CommitObject(g.h)
+
if err != nil {
+
return nil, fmt.Errorf("commit object: %w", err)
+
}
+
+
tree, err := c.Tree()
+
if err != nil {
+
return nil, fmt.Errorf("tree: %w", err)
+
}
+
+
// read .gitmodules file
+
modulesEntry, err := tree.FindEntry(".gitmodules")
+
if err != nil {
+
return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err)
+
}
+
+
modulesFile, err := tree.TreeEntryFile(modulesEntry)
+
if err != nil {
+
return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err)
+
}
+
+
content, err := modulesFile.Contents()
+
if err != nil {
+
return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err)
+
}
+
+
// parse .gitmodules
+
modules := config.NewModules()
+
if err = modules.Unmarshal([]byte(content)); err != nil {
+
return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err)
+
}
+
+
return modules, nil
+
}
+
+
func (g *GitRepo) Submodule(path string) (*config.Submodule, error) {
+
modules, err := g.Submodules()
+
if err != nil {
+
return nil, err
+
}
+
+
for _, submodule := range modules.Submodules {
+
if submodule.Path == path {
+
return submodule, nil
+
}
+
}
+
+
// path is not a submodule
+
return nil, ErrNotSubmodule
}
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+4 -13
knotserver/git/tree.go
···
"path"
"time"
+
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"tangled.org/core/types"
)
···
}
for _, e := range subtree.Entries {
-
mode, _ := e.Mode.ToOSFileMode()
sz, _ := subtree.Size(e.Name)
-
fpath := path.Join(parent, e.Name)
var lastCommit *types.LastCommitInfo
···
nts = append(nts, types.NiceTree{
Name: e.Name,
-
Mode: mode.String(),
-
IsFile: e.Mode.IsFile(),
+
Mode: e.Mode.String(),
Size: sz,
LastCommit: lastCommit,
})
···
default:
}
-
mode, err := e.Mode.ToOSFileMode()
-
if err != nil {
-
// TODO: log this
-
continue
-
}
-
if e.Mode.IsFile() {
-
err = cb(e, currentTree, root)
-
if errors.Is(err, TerminateWalk) {
+
if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) {
return err
}
}
// e is a directory
-
if mode.IsDir() {
+
if e.Mode == filemode.Dir {
subtree, err := currentTree.Tree(e.Name)
if err != nil {
return fmt.Errorf("sub tree %s: %w", e.Name, err)
+1 -1
knotserver/ingester.go
···
var pipeline workflow.RawPipeline
for _, e := range workflowDir {
-
if !e.IsFile {
+
if !e.IsFile() {
continue
}
+1 -1
knotserver/internal.go
···
var pipeline workflow.RawPipeline
for _, e := range workflowDir {
-
if !e.IsFile {
+
if !e.IsFile() {
continue
}
+21 -2
knotserver/xrpc/repo_blob.go
···
return
}
+
// first check if this path is a submodule
+
submodule, err := gr.Submodule(treePath)
+
if err != nil {
+
// this is okay, continue and try to treat it as a regular file
+
} else {
+
response := tangled.RepoBlob_Output{
+
Ref: ref,
+
Path: treePath,
+
Submodule: &tangled.RepoBlob_Submodule{
+
Name: submodule.Name,
+
Url: submodule.URL,
+
Branch: &submodule.Branch,
+
},
+
}
+
writeJson(w, response)
+
return
+
}
+
contents, err := gr.RawContent(treePath)
if err != nil {
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
···
var encoding string
isBinary := !isTextual(mimeType)
+
size := int64(len(contents))
if isBinary {
content = base64.StdEncoding.EncodeToString(contents)
···
response := tangled.RepoBlob_Output{
Ref: ref,
Path: treePath,
-
Content: content,
+
Content: &content,
Encoding: &encoding,
-
Size: &[]int64{int64(len(contents))}[0],
+
Size: &size,
IsBinary: &isBinary,
}
+3 -5
knotserver/xrpc/repo_tree.go
···
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
for i, file := range files {
entry := &tangled.RepoTree_TreeEntry{
-
Name: file.Name,
-
Mode: file.Mode,
-
Size: file.Size,
-
Is_file: file.IsFile,
-
Is_subtree: file.IsSubtree,
+
Name: file.Name,
+
Mode: file.Mode,
+
Size: file.Size,
}
if file.LastCommit != nil {
-14
lexicons/issue/comment.json
···
"replyTo": {
"type": "string",
"format": "at-uri"
-
},
-
"mentions": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "did"
-
}
-
},
-
"references": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "at-uri"
-
}
}
}
}
-14
lexicons/issue/issue.json
···
"createdAt": {
"type": "string",
"format": "datetime"
-
},
-
"mentions": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "did"
-
}
-
},
-
"references": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "at-uri"
-
}
}
}
}
-14
lexicons/pulls/comment.json
···
"createdAt": {
"type": "string",
"format": "datetime"
-
},
-
"mentions": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "did"
-
}
-
},
-
"references": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "at-uri"
-
}
}
}
}
-14
lexicons/pulls/pull.json
···
"createdAt": {
"type": "string",
"format": "datetime"
-
},
-
"mentions": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "did"
-
}
-
},
-
"references": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "at-uri"
-
}
}
}
}
+49 -5
lexicons/repo/blob.json
···
"type": "query",
"parameters": {
"type": "params",
-
"required": ["repo", "ref", "path"],
+
"required": [
+
"repo",
+
"ref",
+
"path"
+
],
"properties": {
"repo": {
"type": "string",
···
"encoding": "application/json",
"schema": {
"type": "object",
-
"required": ["ref", "path", "content"],
+
"required": [
+
"ref",
+
"path"
+
],
"properties": {
"ref": {
"type": "string",
···
"encoding": {
"type": "string",
"description": "Content encoding",
-
"enum": ["utf-8", "base64"]
+
"enum": [
+
"utf-8",
+
"base64"
+
]
},
"size": {
"type": "integer",
···
"mimeType": {
"type": "string",
"description": "MIME type of the file"
+
},
+
"submodule": {
+
"type": "ref",
+
"ref": "#submodule",
+
"description": "Submodule information if path is a submodule"
},
"lastCommit": {
"type": "ref",
···
},
"lastCommit": {
"type": "object",
-
"required": ["hash", "message", "when"],
+
"required": [
+
"hash",
+
"message",
+
"when"
+
],
"properties": {
"hash": {
"type": "string",
···
},
"signature": {
"type": "object",
-
"required": ["name", "email", "when"],
+
"required": [
+
"name",
+
"email",
+
"when"
+
],
"properties": {
"name": {
"type": "string",
···
"type": "string",
"format": "datetime",
"description": "Author timestamp"
+
}
+
}
+
},
+
"submodule": {
+
"type": "object",
+
"required": [
+
"name",
+
"url"
+
],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Submodule name"
+
},
+
"url": {
+
"type": "string",
+
"description": "Submodule repository URL"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Branch to track in the submodule"
}
}
}
+1 -9
lexicons/repo/tree.json
···
},
"treeEntry": {
"type": "object",
-
"required": ["name", "mode", "size", "is_file", "is_subtree"],
+
"required": ["name", "mode", "size"],
"properties": {
"name": {
"type": "string",
···
"size": {
"type": "integer",
"description": "File size in bytes"
-
},
-
"is_file": {
-
"type": "boolean",
-
"description": "Whether this entry is a file"
-
},
-
"is_subtree": {
-
"type": "boolean",
-
"description": "Whether this entry is a directory/subtree"
},
"last_commit": {
"type": "ref",
+278 -12
nix/modules/appview.nix
···
default = false;
description = "Enable tangled appview";
};
+
package = mkOption {
type = types.package;
description = "Package to use for the appview";
};
+
+
# core configuration
port = mkOption {
-
type = types.int;
+
type = types.port;
default = 3000;
description = "Port to run the appview on";
};
+
+
listenAddr = mkOption {
+
type = types.str;
+
default = "0.0.0.0:${toString cfg.port}";
+
description = "Listen address for the appview service";
+
};
+
+
dbPath = mkOption {
+
type = types.str;
+
default = "/var/lib/appview/appview.db";
+
description = "Path to the SQLite database file";
+
};
+
+
appviewHost = mkOption {
+
type = types.str;
+
default = "https://tangled.org";
+
example = "https://example.com";
+
description = "Public host URL for the appview instance";
+
};
+
+
appviewName = mkOption {
+
type = types.str;
+
default = "Tangled";
+
description = "Display name for the appview instance";
+
};
+
+
dev = mkOption {
+
type = types.bool;
+
default = false;
+
description = "Enable development mode";
+
};
+
+
disallowedNicknamesFile = mkOption {
+
type = types.nullOr types.path;
+
default = null;
+
description = "Path to file containing disallowed nicknames";
+
};
+
+
# redis configuration
+
redis = {
+
addr = mkOption {
+
type = types.str;
+
default = "localhost:6379";
+
description = "Redis server address";
+
};
+
+
db = mkOption {
+
type = types.int;
+
default = 0;
+
description = "Redis database number";
+
};
+
};
+
+
# jetstream configuration
+
jetstream = {
+
endpoint = mkOption {
+
type = types.str;
+
default = "wss://jetstream1.us-east.bsky.network/subscribe";
+
description = "Jetstream WebSocket endpoint";
+
};
+
};
+
+
# knotstream consumer configuration
+
knotstream = {
+
retryInterval = mkOption {
+
type = types.str;
+
default = "60s";
+
description = "Initial retry interval for knotstream consumer";
+
};
+
+
maxRetryInterval = mkOption {
+
type = types.str;
+
default = "120m";
+
description = "Maximum retry interval for knotstream consumer";
+
};
+
+
connectionTimeout = mkOption {
+
type = types.str;
+
default = "5s";
+
description = "Connection timeout for knotstream consumer";
+
};
+
+
workerCount = mkOption {
+
type = types.int;
+
default = 64;
+
description = "Number of workers for knotstream consumer";
+
};
+
+
queueSize = mkOption {
+
type = types.int;
+
default = 100;
+
description = "Queue size for knotstream consumer";
+
};
+
};
+
+
# spindlestream consumer configuration
+
spindlestream = {
+
retryInterval = mkOption {
+
type = types.str;
+
default = "60s";
+
description = "Initial retry interval for spindlestream consumer";
+
};
+
+
maxRetryInterval = mkOption {
+
type = types.str;
+
default = "120m";
+
description = "Maximum retry interval for spindlestream consumer";
+
};
+
+
connectionTimeout = mkOption {
+
type = types.str;
+
default = "5s";
+
description = "Connection timeout for spindlestream consumer";
+
};
+
+
workerCount = mkOption {
+
type = types.int;
+
default = 64;
+
description = "Number of workers for spindlestream consumer";
+
};
+
+
queueSize = mkOption {
+
type = types.int;
+
default = 100;
+
description = "Queue size for spindlestream consumer";
+
};
+
};
+
+
# resend configuration
+
resend = {
+
sentFrom = mkOption {
+
type = types.str;
+
default = "noreply@notifs.tangled.sh";
+
description = "Email address to send notifications from";
+
};
+
};
+
+
# posthog configuration
+
posthog = {
+
endpoint = mkOption {
+
type = types.str;
+
default = "https://eu.i.posthog.com";
+
description = "PostHog API endpoint";
+
};
+
};
+
+
# camo configuration
+
camo = {
+
host = mkOption {
+
type = types.str;
+
default = "https://camo.tangled.sh";
+
description = "Camo proxy host URL";
+
};
+
};
+
+
# avatar configuration
+
avatar = {
+
host = mkOption {
+
type = types.str;
+
default = "https://avatar.tangled.sh";
+
description = "Avatar service host URL";
+
};
+
};
+
+
plc = {
+
url = mkOption {
+
type = types.str;
+
default = "https://plc.directory";
+
description = "PLC directory URL";
+
};
+
};
+
+
pds = {
+
host = mkOption {
+
type = types.str;
+
default = "https://tngl.sh";
+
description = "PDS host URL";
+
};
+
};
+
+
label = {
+
defaults = mkOption {
+
type = types.listOf types.str;
+
default = [
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix"
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate"
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation"
+
"at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"
+
];
+
description = "Default label definitions";
+
};
+
+
goodFirstIssue = mkOption {
+
type = types.str;
+
default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue";
+
description = "Good first issue label definition";
+
};
+
};
+
environmentFile = mkOption {
type = with types; nullOr path;
default = null;
-
example = "/etc-/appview.env";
+
example = "/etc/appview.env";
description = ''
Additional environment file as defined in {manpage}`systemd.exec(5)`.
-
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
-
passed to the service without makeing them world readable in the
-
nix store.
-
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`,
+
{env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`,
+
{env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`,
+
{env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`,
+
{env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`,
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`,
+
{env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`,
+
{env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`,
+
and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service
+
without making them world readable in the nix store.
'';
};
};
···
systemd.services.appview = {
description = "tangled appview service";
wantedBy = ["multi-user.target"];
-
after = ["redis-appview.service"];
+
after = ["redis-appview.service" "network-online.target"];
requires = ["redis-appview.service"];
+
wants = ["network-online.target"];
serviceConfig = {
-
ListenStream = "0.0.0.0:${toString cfg.port}";
+
Type = "simple";
ExecStart = "${cfg.package}/bin/appview";
Restart = "always";
-
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
-
};
+
RestartSec = "10s";
+
EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
+
+
# state directory
+
StateDirectory = "appview";
+
WorkingDirectory = "/var/lib/appview";
-
environment = {
-
TANGLED_DB_PATH = "appview.db";
+
# security hardening
+
NoNewPrivileges = true;
+
PrivateTmp = true;
+
ProtectSystem = "strict";
+
ProtectHome = true;
+
ReadWritePaths = ["/var/lib/appview"];
};
+
+
environment =
+
{
+
TANGLED_DB_PATH = cfg.dbPath;
+
TANGLED_LISTEN_ADDR = cfg.listenAddr;
+
TANGLED_APPVIEW_HOST = cfg.appviewHost;
+
TANGLED_APPVIEW_NAME = cfg.appviewName;
+
TANGLED_DEV =
+
if cfg.dev
+
then "true"
+
else "false";
+
}
+
// optionalAttrs (cfg.disallowedNicknamesFile != null) {
+
TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile;
+
}
+
// {
+
TANGLED_REDIS_ADDR = cfg.redis.addr;
+
TANGLED_REDIS_DB = toString cfg.redis.db;
+
+
TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint;
+
+
TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval;
+
TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval;
+
TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout;
+
TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount;
+
TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize;
+
+
TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval;
+
TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval;
+
TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout;
+
TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount;
+
TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize;
+
+
TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom;
+
+
TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint;
+
+
TANGLED_CAMO_HOST = cfg.camo.host;
+
+
TANGLED_AVATAR_HOST = cfg.avatar.host;
+
+
TANGLED_PLC_URL = cfg.plc.url;
+
+
TANGLED_PDS_HOST = cfg.pds.host;
+
+
TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults;
+
TANGLED_LABEL_GFI = cfg.label.goodFirstIssue;
+
};
};
};
}
+58 -2
nix/modules/knot.nix
···
description = "Path where repositories are scanned from";
};
+
readme = mkOption {
+
type = types.listOf types.str;
+
default = [
+
"README.md"
+
"readme.md"
+
"README"
+
"readme"
+
"README.markdown"
+
"readme.markdown"
+
"README.txt"
+
"readme.txt"
+
"README.rst"
+
"readme.rst"
+
"README.org"
+
"readme.org"
+
"README.asciidoc"
+
"readme.asciidoc"
+
];
+
description = "List of README filenames to look for (in priority order)";
+
};
+
mainBranch = mkOption {
type = types.str;
default = "main";
···
};
};
+
git = {
+
userName = mkOption {
+
type = types.str;
+
default = "Tangled";
+
description = "Git user name used as committer";
+
};
+
+
userEmail = mkOption {
+
type = types.str;
+
default = "noreply@tangled.org";
+
description = "Git user email used as committer";
+
};
+
};
+
motd = mkOption {
type = types.nullOr types.str;
default = null;
···
description = "Jetstream endpoint to subscribe to";
};
+
logDids = mkOption {
+
type = types.bool;
+
default = true;
+
description = "Enable logging of DIDs";
+
};
+
dev = mkOption {
type = types.bool;
default = false;
···
mkdir -p "${cfg.stateDir}/.config/git"
cat > "${cfg.stateDir}/.config/git/config" << EOF
[user]
-
name = Git User
-
email = git@example.com
+
name = ${cfg.git.userName}
+
email = ${cfg.git.userEmail}
[receive]
advertisePushOptions = true
+
[uploadpack]
+
allowFilter = true
EOF
${setMotd}
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
···
WorkingDirectory = cfg.stateDir;
Environment = [
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
+
"KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
+
"KNOT_GIT_USER_NAME=${cfg.git.userName}"
+
"KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
···
"KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
"KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
"KNOT_SERVER_OWNER=${cfg.server.owner}"
+
"KNOT_SERVER_LOG_DIDS=${
+
if cfg.server.logDids
+
then "true"
+
else "false"
+
}"
+
"KNOT_SERVER_DEV=${
+
if cfg.server.dev
+
then "true"
+
else "false"
+
}"
];
ExecStart = "${cfg.package}/bin/knot server";
Restart = "always";
+2
nix/pkgs/appview-static-files.nix
···
lucide-src,
inter-fonts-src,
ibm-plex-mono-src,
+
actor-typeahead-src,
sqlite-lib,
tailwindcss,
src,
···
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
+
cp -f ${actor-typeahead-src}/actor-typeahead.js .
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
# for whatever reason (produces broken css), so we are doing this instead
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+1 -1
nix/pkgs/knot-unwrapped.nix
···
sqlite-lib,
src,
}: let
-
version = "1.9.1-alpha";
+
version = "1.11.0-alpha";
in
buildGoApplication {
pname = "knot";
+1 -1
nix/vm.nix
···
enable = true;
server = {
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
-
hostname = envVarOr "TANGLED_VM_SPINDLE_OWNER" "localhost:6555";
+
hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
plcUrl = plcUrl;
jetstreamEndpoint = jetstream;
listenAddr = "0.0.0.0:6555";
+1 -1
spindle/config/config.go
···
DBPath string `env:"DB_PATH, default=spindle.db"`
Hostname string `env:"HOSTNAME, required"`
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
-
PlcUrl string `env:"PLC_URL, default=plc.directory"`
+
PlcUrl string `env:"PLC_URL, default=https://plc.directory"`
Dev bool `env:"DEV, default=false"`
Owner string `env:"OWNER, required"`
Secrets Secrets `env:",prefix=SECRETS_"`
+15 -7
spindle/secrets/openbao.go
···
)
type OpenBaoManager struct {
-
client *vault.Client
-
mountPath string
-
logger *slog.Logger
+
client *vault.Client
+
mountPath string
+
logger *slog.Logger
+
connectionTimeout time.Duration
}
type OpenBaoManagerOpt func(*OpenBaoManager)
···
}
}
+
func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt {
+
return func(v *OpenBaoManager) {
+
v.connectionTimeout = timeout
+
}
+
}
+
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
// The proxy handles all authentication automatically via Auto-Auth
···
}
manager := &OpenBaoManager{
-
client: client,
-
mountPath: "spindle", // default KV v2 mount path
-
logger: logger,
+
client: client,
+
mountPath: "spindle", // default KV v2 mount path
+
logger: logger,
+
connectionTimeout: 10 * time.Second, // default connection timeout
}
for _, opt := range opts {
···
// testConnection verifies that we can connect to the proxy
func (v *OpenBaoManager) testConnection() error {
-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout)
defer cancel()
// try token self-lookup as a quick way to verify proxy works
+5 -2
spindle/secrets/openbao_test.go
···
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
+
// Use shorter timeout for tests to avoid long waits
+
opts := append(tt.opts, WithConnectionTimeout(1*time.Second))
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, opts...)
if tt.expectError {
assert.Error(t, err)
···
// All these will fail because no real proxy is running
// but we can test that the configuration is properly accepted
-
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
+
// Use shorter timeout for tests to avoid long waits
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second))
assert.Error(t, err) // Expected because no real proxy
assert.Nil(t, manager)
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+85 -40
spindle/server.go
···
vault secrets.Manager
}
-
func Run(ctx context.Context) error {
+
// New creates a new Spindle server with the provided configuration and engines.
+
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
logger := log.FromContext(ctx)
-
-
cfg, err := config.Load(ctx)
-
if err != nil {
-
return fmt.Errorf("failed to load config: %w", err)
-
}
d, err := db.Make(cfg.Server.DBPath)
if err != nil {
-
return fmt.Errorf("failed to setup db: %w", err)
+
return nil, fmt.Errorf("failed to setup db: %w", err)
}
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
if err != nil {
-
return fmt.Errorf("failed to setup rbac enforcer: %w", err)
+
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
}
e.E.EnableAutoSave(true)
···
switch cfg.Server.Secrets.Provider {
case "openbao":
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
-
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
+
return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
}
vault, err = secrets.NewOpenBaoManager(
cfg.Server.Secrets.OpenBao.ProxyAddr,
···
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
)
if err != nil {
-
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
+
return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err)
}
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
case "sqlite", "":
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
if err != nil {
-
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
+
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
}
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
default:
-
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
-
}
-
-
nixeryEng, err := nixery.New(ctx, cfg)
-
if err != nil {
-
return err
+
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
}
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
···
}
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
if err != nil {
-
return fmt.Errorf("failed to setup jetstream client: %w", err)
+
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
}
jc.AddDid(cfg.Server.Owner)
// Check if the spindle knows about any Dids;
dids, err := d.GetAllDids()
if err != nil {
-
return fmt.Errorf("failed to get all dids: %w", err)
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
}
for _, d := range dids {
jc.AddDid(d)
···
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
-
spindle := Spindle{
+
spindle := &Spindle{
jc: jc,
e: e,
db: d,
l: logger,
n: &n,
-
engs: map[string]models.Engine{"nixery": nixeryEng},
+
engs: engines,
jq: jq,
cfg: cfg,
res: resolver,
···
err = e.AddSpindle(rbacDomain)
if err != nil {
-
return fmt.Errorf("failed to set rbac domain: %w", err)
+
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
}
err = spindle.configureOwner()
if err != nil {
-
return err
+
return nil, err
}
logger.Info("owner set", "did", cfg.Server.Owner)
-
// starts a job queue runner in the background
-
jq.Start()
-
defer jq.Stop()
-
-
// Stop vault token renewal if it implements Stopper
-
if stopper, ok := vault.(secrets.Stopper); ok {
-
defer stopper.Stop()
-
}
-
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
if err != nil {
-
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
+
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
}
err = jc.StartJetstream(ctx, spindle.ingest())
if err != nil {
-
return fmt.Errorf("failed to start jetstream consumer: %w", err)
+
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
}
// for each incoming sh.tangled.pipeline, we execute
···
ccfg.CursorStore = cursorStore
knownKnots, err := d.Knots()
if err != nil {
-
return err
+
return nil, err
}
for _, knot := range knownKnots {
logger.Info("adding source start", "knot", knot)
···
}
spindle.ks = eventconsumer.NewConsumer(*ccfg)
+
return spindle, nil
+
}
+
+
// DB returns the database instance.
+
func (s *Spindle) DB() *db.DB {
+
return s.db
+
}
+
+
// Queue returns the job queue instance.
+
func (s *Spindle) Queue() *queue.Queue {
+
return s.jq
+
}
+
+
// Engines returns the map of available engines.
+
func (s *Spindle) Engines() map[string]models.Engine {
+
return s.engs
+
}
+
+
// Vault returns the secrets manager instance.
+
func (s *Spindle) Vault() secrets.Manager {
+
return s.vault
+
}
+
+
// Notifier returns the notifier instance.
+
func (s *Spindle) Notifier() *notifier.Notifier {
+
return s.n
+
}
+
+
// Enforcer returns the RBAC enforcer instance.
+
func (s *Spindle) Enforcer() *rbac.Enforcer {
+
return s.e
+
}
+
+
// Start starts the Spindle server (blocking).
+
func (s *Spindle) Start(ctx context.Context) error {
+
// starts a job queue runner in the background
+
s.jq.Start()
+
defer s.jq.Stop()
+
+
// Stop vault token renewal if it implements Stopper
+
if stopper, ok := s.vault.(secrets.Stopper); ok {
+
defer stopper.Stop()
+
}
+
go func() {
-
logger.Info("starting knot event consumer")
-
spindle.ks.Start(ctx)
+
s.l.Info("starting knot event consumer")
+
s.ks.Start(ctx)
}()
-
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
+
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
+
return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router())
+
}
+
+
func Run(ctx context.Context) error {
+
cfg, err := config.Load(ctx)
+
if err != nil {
+
return fmt.Errorf("failed to load config: %w", err)
+
}
+
+
nixeryEng, err := nixery.New(ctx, cfg)
+
if err != nil {
+
return err
+
}
+
+
s, err := New(ctx, cfg, map[string]models.Engine{
+
"nixery": nixeryEng,
+
})
+
if err != nil {
+
return err
+
}
-
return nil
+
return s.Start(ctx)
}
func (s *Spindle) Router() http.Handler {
+5
spindle/stream.go
···
if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil {
return fmt.Errorf("failed to write to websocket: %w", err)
}
+
case <-time.After(30 * time.Second):
+
// send a keep-alive
+
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
+
return fmt.Errorf("failed to write control: %w", err)
+
}
}
}
}
+22 -1
types/repo.go
···
package types
import (
+
"encoding/json"
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
"github.com/go-git/go-git/v5/plumbing/object"
)
···
type Branch struct {
Reference `json:"reference"`
Commit *object.Commit `json:"commit,omitempty"`
-
IsDefault bool `json:"is_deafult,omitempty"`
+
IsDefault bool `json:"is_default,omitempty"`
+
}
+
+
func (b *Branch) UnmarshalJSON(data []byte) error {
+
aux := &struct {
+
Reference `json:"reference"`
+
Commit *object.Commit `json:"commit,omitempty"`
+
IsDefault bool `json:"is_default,omitempty"`
+
MispelledIsDefault bool `json:"is_deafult,omitempty"` // mispelled name
+
}{}
+
+
if err := json.Unmarshal(data, aux); err != nil {
+
return err
+
}
+
+
b.Reference = aux.Reference
+
b.Commit = aux.Commit
+
b.IsDefault = aux.IsDefault || aux.MispelledIsDefault // whichever was set
+
+
return nil
}
type RepoTagsResponse struct {
+88 -5
types/tree.go
···
package types
import (
+
"fmt"
+
"os"
"time"
"github.com/go-git/go-git/v5/plumbing"
+
"github.com/go-git/go-git/v5/plumbing/filemode"
)
// A nicer git tree representation.
type NiceTree struct {
// Relative path
-
Name string `json:"name"`
-
Mode string `json:"mode"`
-
Size int64 `json:"size"`
-
IsFile bool `json:"is_file"`
-
IsSubtree bool `json:"is_subtree"`
+
Name string `json:"name"`
+
Mode string `json:"mode"`
+
Size int64 `json:"size"`
LastCommit *LastCommitInfo `json:"last_commit,omitempty"`
+
}
+
+
func (t *NiceTree) FileMode() (filemode.FileMode, error) {
+
if numericMode, err := filemode.New(t.Mode); err == nil {
+
return numericMode, nil
+
}
+
+
// TODO: this is here for backwards compat, can be removed in future versions
+
osMode, err := parseModeString(t.Mode)
+
if err != nil {
+
return filemode.Empty, nil
+
}
+
+
conv, err := filemode.NewFromOSFileMode(osMode)
+
if err != nil {
+
return filemode.Empty, nil
+
}
+
+
return conv, nil
+
}
+
+
// ParseFileModeString parses a file mode string like "-rw-r--r--"
+
// and returns an os.FileMode
+
func parseModeString(modeStr string) (os.FileMode, error) {
+
if len(modeStr) != 10 {
+
return 0, fmt.Errorf("invalid mode string length: expected 10, got %d", len(modeStr))
+
}
+
+
var mode os.FileMode
+
+
// Parse file type (first character)
+
switch modeStr[0] {
+
case 'd':
+
mode |= os.ModeDir
+
case 'l':
+
mode |= os.ModeSymlink
+
case '-':
+
// regular file
+
default:
+
return 0, fmt.Errorf("unknown file type: %c", modeStr[0])
+
}
+
+
// parse permissions for owner, group, and other
+
perms := modeStr[1:]
+
shifts := []int{6, 3, 0} // bit shifts for owner, group, other
+
+
for i := range 3 {
+
offset := i * 3
+
shift := shifts[i]
+
+
if perms[offset] == 'r' {
+
mode |= os.FileMode(4 << shift)
+
}
+
if perms[offset+1] == 'w' {
+
mode |= os.FileMode(2 << shift)
+
}
+
if perms[offset+2] == 'x' {
+
mode |= os.FileMode(1 << shift)
+
}
+
}
+
+
return mode, nil
+
}
+
+
func (t *NiceTree) IsFile() bool {
+
m, err := t.FileMode()
+
+
if err != nil {
+
return false
+
}
+
+
return m.IsFile()
+
}
+
+
func (t *NiceTree) IsSubmodule() bool {
+
m, err := t.FileMode()
+
+
if err != nil {
+
return false
+
}
+
+
return m == filemode.Submodule
}
type LastCommitInfo struct {